The problem — and why it matters

You have a service that works on your machine. Getting it in front of a user means a repo, a build, somewhere to keep the image, a URL, and a login screen — and none of that is the thing you set out to build.

That last mile is normally stitched from four vendors: GitHub for source, GitHub Actions for CI, Docker Hub or ECR for the image, Fly/Render/Cloud Run for the endpoint — plus a fifth (Auth0, Cognito) the moment a human has to sign in. Five bills, five auth contexts, five places a token can leak, and a supply chain that leaves your perimeter five times before it serves a single request. Every one of those hops is also a thing to wire up again for the next service, and a thing to explain to whoever inherits it.

The tutorials on this blog have the same shape of gap: each one hands you a working engine — a lead pipeline, a CRM, a RAG cache — correct handler, working SQL, a Models gate returning a verdict — and then stops at "shown-as-code.

DODIL closes the loop inside one org, and — this is the part that matters — it closes it declaratively, from the repo:

push → the forge runs .github/workflows/ci.yml with act → an image in registry.dodil.io.dodil/deploy.yaml CD applies it → an Ignite app → the gateway does end-user auth.

Two files in your repo are the pipeline. There is no console to click, no deploy command in a runbook, no CI secret to paste — the runner injects the registry credential itself. And the money argument is the same one that makes CD worth doing anywhere: a cold build of this image takes 12–13 minutes. A deploy path where a typo is discovered after those 13 minutes, by a human running a command from memory, is a path that burns an afternoon per mistake. A checked-in manifest is parsed and reported as its own check — dodil-ci/deploy-config goes red with the line number, and the next push fixes it.

What you'll build: a DODIL git repo with a push credential that can actually push → a workflow that builds and pushes an image with zero configured secrets → a Dockerfile that survives Ignite's non-root admission → a .dodil/deploy.yaml that creates and redeploys the app on every green build, with its DataK3 credential resolved from the repo's secret store instead of committed → a private URL where the gateway logs your business users in and your app ships no auth code → versions and one-command rollback.

What you'll learn:

  • Why the push credential is git.editor, and what git.ci actually can't do.
  • The workflow shape the forge runs, and the three provenance env vars that link an image back to its commit.
  • The whole .dodil/deploy.yaml v1 schema — including the tri-state labels and user_pool keys that quietly replace or detach things if you get them wrong.
  • How a manifest env value references a repo CI secret, so the app's service-account secret reaches the runtime without ever living in git — and why a declared env now owns the app's whole environment.
  • The failure mode that bites after a green build and a perfect image pull: a non-numeric USER.
  • How user_pool turns a public FQDN into a login-gated one without a line of app code.

Prerequisites

  • The dodil CLI (dodil auth login) and an agent connected over MCP (Claude Code, Cursor, VS Code, Codex).
  • git on your PATH — DODIL git speaks smart-HTTP.
  • Names, set once. The git org slug is case-sensitive (IHDIASH); the registry/app org is lowercase (ihdiash). This tutorial uses the real names from the build that validated it.
export ORG=IHDIASH                # git org slug (exact case)
export ORG_LC=ihdiash             # registry + app org (lowercase)
export REPO=retail-vision-app     # the git repo
export IMAGE=retail-vision        # the registry image name
export APP=retail-hq              # the Ignite app name
export POOL=retail-vision         # the dodil-appid pool (Step 7)

Step 1 — Source in DODIL git, with a key that can actually push

Three moves: create the repo, mint a push credential, push. New repos are born private and the dk_ key is the HTTP Basic password.

The credential is the first place people lose an hour. dodil auth apikey issue takes both a --role and a --service, and they must agree: git.* roles need --service git (omitting it defaults to k3 and the role is rejected). More importantly, pick the right git role:

  • git.editor — the contributor role. Clone and push. This is what you want.
  • git.ci — clone and report checks only. Pushing with it fails at the remote with remote: forbidden: insufficient scope. It is the role the runner holds, not you.
You

Create a DODIL git repo called retail-vision-app under org IHDIASH, mint a git.editor push key for it, and give me the clone URL.

DODIL MCP tools called
git_repo_createauth_apikey_issuegit_clone-url
Agent

Repo retail-vision-app created under IHDIASH (repo_id 01a06e47-93dc-7143-aad9-baa2ccdc3c9c, default branch main, visibility private). Issued a git.editor key on the git service — principalId and secret returned once. Clone URL: https://git.dodil.io/IHDIASH/retail-vision-app.git.

Push with the principal id as the username and the dk_ secret as the password:

git init -b main && git add -A && git commit -m "app + ci + deploy manifest"
git push "https://$GIT_USER:$GIT_PASS@git.dodil.io/$ORG/$REPO.git" main
git rev-parse HEAD    # the SHA every check below reports against

TIP

Want a review gate before anything can deploy? dodil git protection set $REPO --branch main --required-approvals 1 --require-green-checks, then land changes through a PR. The ci/build check from Step 2 is exactly what --require-green-checks waits on — and when.require_checks in Step 4 makes the deploy wait on it too.

Step 2 — The build: one workflow, zero configured secrets

The forge runs the workflows under .github/workflows/ with act — GitHub-Actions syntax, DODIL runners. The thing that makes this short: the runner injects the registry credential itself. Before your job starts it appends two secrets to the run — DODIL_REGISTRY_URL and DODIL_REGISTRY_TOKEN, a stable per-org dk_ key holding registry.developer, masked in logs the way GitHub masks GITHUB_TOKEN. There is nothing to paste into a settings page, and no long-lived registry password living in your repo.

The Basic-auth username for that token is the literal string ci.

The runner also injects three non-secret run facts as plain env — deliberately not secrets, because a SHA masked as *** would redact half your build log:

env varwhat it iswhy you want it
DODIL_GIT_SHAthe commit being builtorg.opencontainers.image.revision
DODIL_GIT_REPO_URLthe repo's id-form clone URLorg.opencontainers.image.source
DODIL_GIT_REPO_IDthe repo's opaque idio.dodil.git.repo_id — the join key the registry uses to link an image back to its source repo (opaque and rename-proof, never the org/name string)

Stamp all three as OCI annotations and the image is self-describing forever:

# .github/workflows/ci.yml
name: ci
on: [push]
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      # The runner injected these — nothing is configured in the repo.
      - uses: docker/login-action@v3
        with:
          registry: ${{ secrets.DODIL_REGISTRY_URL }}
          username: ci
          password: ${{ secrets.DODIL_REGISTRY_TOKEN }}
 
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.DODIL_REGISTRY_URL }}/ihdiash/retail-vision:${{ github.sha }}
            ${{ secrets.DODIL_REGISTRY_URL }}/ihdiash/retail-vision:latest
          # Provenance: the registry -> git carrier.
          annotations: |
            org.opencontainers.image.source=${{ env.DODIL_GIT_REPO_URL }}
            org.opencontainers.image.revision=${{ env.DODIL_GIT_SHA }}
            io.dodil.git.repo_id=${{ env.DODIL_GIT_REPO_ID }}
          labels: |
            org.opencontainers.image.source=${{ env.DODIL_GIT_REPO_URL }}
            org.opencontainers.image.revision=${{ env.DODIL_GIT_SHA }}
            io.dodil.git.repo_id=${{ env.DODIL_GIT_REPO_ID }}

The check that appears on the commit is named <workflow>/<job> — for the file above, ci/build. Tag both the SHA and latest: the SHA tag is what CD deploys (it is what makes a rollback meaningful), and latest is the human-friendly handle for the manual ignite app deploy --image-ref escape hatch.

NOTE

Pulling your own org's image needs no secret. Ignite (v1.0.5+) authenticates same-org pulls from registry.dodil.io itself, so an image your CI just pushed needs no auth apikey issue, no ignite secret create --type registry, and no --registry-secret-ref — the ref alone is enough. A ref belonging to another org is rejected at deploy time with an explanatory error (it "pulls another org's project through the platform registry door — restricted to admin-registry.dodil.io/<org>/…"). Only a genuinely external registry (Docker Hub, ECR, a partner's) still needs dodil ignite secret create <name> --type registry --username … --password … --server-address <host> plus --registry-secret-ref <name> — a bare name, never org/name, which fails Kubernetes name validation.

Step 3 — The Dockerfile: a NUMERIC non-root USER

This is the gotcha that costs the most, because it fails after everything looks fine — the build is green, the image pushes, the pod pulls successfully, and then the kubelet refuses to start it:

container has runAsNonRoot and image will run as root

Ignite admits pods with runAsNonRoot. Two consequences:

  1. A root image is rejected. Default python:*/node:* images run as root.
  2. A username is not enough. USER app still fails — the kubelet reads the image config, where USER app is an unresolved string; it cannot look up /etc/passwd inside an image it hasn't started. The uid must be numeric in the final USER.
FROM python:3.12-slim
WORKDIR /app
 
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
 
# Ignite admits pods with runAsNonRoot. Create a real uid, hand it the app dir,
# and declare it NUMERICALLY — 'USER app' is not resolvable from the image config.
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin app \
    && chown -R 10001:10001 /app
USER 10001
 
ENV PORT=8080
EXPOSE 8080
CMD ["python", "-m", "uvicorn", "app:api", "--host", "0.0.0.0", "--port", "8080"]

Port 8080 is unprivileged, so nothing else has to change. Bind 0.0.0.0 (not 127.0.0.1) or the health probe never reaches you.

Step 4 — The deploy manifest: .dodil/deploy.yaml

After the checks for a SHA settle, the CD runner looks for .dodil/deploy.yaml at that SHA. Missing file = no deploy (not an error). Present = it is parsed strictly and applied.

The schema is deny_unknown_fields everywhere: a typo'd key is a hard parse error surfaced as one errored dodil-ci/deploy-config check with the offending line — never a silent skip, never a half-applied deploy.

# .dodil/deploy.yaml
version: 1
 
# Opt-in: on the FIRST deploy, CREATE the app if it doesn't exist yet.
# Default false, on purpose — otherwise a typo'd `name` silently spawns a new app.
create_if_missing: true
 
apps:
  - name: retail-hq                                     # ignite id rules: [a-z0-9.-], no _, no caps
    image: "{registry}/ihdiash/retail-vision:{sha}"      # templates below
    port: 8080
    resources:
      tier: medium                                       # small | medium | large | xlarge
      memory_mb: 1024                                    # explicit limits override the tier, per field
    scaling:
      max_replicas: 4                                    # also: scale_threshold, scaledown_period_secs
    group: retail                                        # sugar for labels.group
    labels:                                              # TRI-STATE — read the note below
      team: demo
    user_pool: retail-vision                             # TRI-STATE — bare name, org-normalized
    public_invoke: false                                 # false => login required. CREATE-time only.
    env:                                                 # DECLARED = REPLACES the app's env map (Step 5)
      BUCKET: retailvis-core01
    health:
      path: /healthz
 
when:
  branches: [main]          # empty/absent = the repo's default branch
  require_checks: all       # all | none | [explicit check names]

The image template. image: accepts exactly six placeholders — {registry}, {org}, {repo}, {sha}, {short_sha} (12 chars), {branch}. Anything else, or an unbalanced brace, is a parse error rather than a render-time surprise. Deploy the {sha} tag, not latest: an immutable per-commit tag is what makes ignite version rollback mean something.

The two tri-state keys — this is where declarative bites. labels and user_pool each have three states, and the middle one is easy to trigger by accident:

you writewhat happens
key absentnever touched — labels set from the CLI/console survive every redeploy
labels: {}clears all labels
labels: {team: demo}REPLACES the whole map — Ignite has no per-key merge
user_pool: (no value)YAML null = absent = no change
user_pool: ""detaches the pool — the app stops asking anyone to log in
user_pool: retail-visionattaches it, re-sent every deploy (idempotent)

Write the bare pool name. The runner expands it against your org and normalises the case on both sides, so retail-vision resolves correctly even though the git org slug is IHDIASH and the app org is ihdiash — a bare name is the portable form, and pinning org/pool by hand is no longer worth doing.

Declaring labels or group makes this file the owner of the whole label map. And group: retail alongside a different explicit labels.group is a parse error — two answers in one declarative file is a typo, not a merge.

What is deliberately not here: reserved_capacity. Always-warm pods bill continuously, so keeping a floor of replicas is a platform/billing decision, not a repo-file one — set it out of band with dodil ignite app update $APP --reserved 1. public_invoke is likewise only consulted at create time; flipping it later in the file does nothing, deliberately, so a repo edit can never silently make a private app public.

The tier vocabulary is Ignite's own, verbatim: small | medium | large | xlarge, with standard kept as a deprecated alias of medium so older checked-in manifests keep parsing. (Earlier runners spoke a different enum and rejected medium thirteen minutes into a build; that divergence is fixed.)

Step 5 — The runtime credential: a secret reference, not a committed one

Here is the question every one of these tutorials eventually runs into: how does the app get its database credential? The engine you built needs DODIL_SERVICE_ACCOUNT_ID and DODIL_SERVICE_ACCOUNT_SECRET to reach its DataK3 bucket over the pg wire. Until recently there were only two ways to supply it, and both were wrong:

  1. Put it in env: — a live credential committed to git, in a repo your whole team clones.
  2. Set it out of band with ignite app deploy --env … — which replaces the app's whole env map, so the next deploy silently drops something, every DB route 500s with a KeyError, and recovering means rotating a secret that is not retrievable and is probably used elsewhere.

Neither is a GitOps story. So the manifest gained a third option: an env value may reference a secret from the repo's CI secret store — the same store the build job's secrets.* come from — and the CD runner resolves it at deploy time.

# .dodil/deploy.yaml (excerpt) — the value never exists in git
    env:
      BUCKET: retailvis-core01
      WEB_DIST: ./web_dist
      DODIL_SERVICE_ACCOUNT_ID: cli-retail-vision-app
      DODIL_SERVICE_ACCOUNT_SECRET: "${{ secrets.RETAIL_SA }}"

Three properties, and each one is deliberate:

  • The plaintext never touches the repo. Substitution happens in the runner, where the secret already lives; resolved values go through the same run-scoped masker the build log uses, so they cannot reach a check detail or a log line.
  • A missing secret fails loudly, before anything ships. Reference secrets.RETAIL_SA without setting it and the app's deploy fails with the name in the check detail — not a pod that starts and 500s on its first request.
  • Only that one form is accepted. Any other ${{ … }} expression is a parse error at the dodil-ci/deploy-config check. There is no shell, no expression language, no way to smuggle a command into a deploy.

IMPORTANT

A declared env now REPLACES the app's env map on every deploy. That is the other half of the same fix: the file owns the environment, so an out-of-band --env edit can no longer be quietly dropped by the next CD apply — and it can no longer be relied on either. Declare every runtime variable your app reads in the manifest. (An absent or empty env: still means "don't touch".)

Setting the secret

The store is the repo's CI secrets — the same store ${{ secrets.* }} reads in workflows. Names are listable, values are never returned by anything, and writing one needs git.admin: git.editor gets forbidden: insufficient scope here, which is the point — pushing code and writing deploy-time credentials are different powers.

# NAME=VALUE sets it outright; passing just NAME prompts, which keeps the
# secret out of your shell history — prefer that on a real machine.
dodil git secret set retail-vision-app RETAIL_SA
dodil git secret list retail-vision-app     # names + timestamps only
dodil git secret delete retail-vision-app RETAIL_SA

There is also a REST surface if you're scripting from CI rather than a shell — PUT https://git.dodil.io/api/v1/repos/{repo_id}/ci-secrets/{NAME} with {"value":"…"} returns 204, Bearer auth, same git.admin requirement; GET …/ci-secrets lists names.

That closes the loop the rest of this post opened: source, build, image and deploy are all declarative, and now the one thing that could never be declarative — the credential — is a reference in the file and a value in a store, versioned by the commit that names it.

Step 6 — Push, then watch the two checks

Push. That is the whole deploy command. What you watch is the check list on the SHA, which tells the story in order: ci/build runs the workflow; then the CD phase reports dodil-ci/deploy-config if the manifest is malformed, or dodil-ci/deploy for the apply itself. Per-app failure reasons now land in the check detail, so a red deploy names the app and the reason instead of making you dig.

You

Show me the CI checks on the latest commit of retail-vision-app in org IHDIASH, tell me what state each one is in, and then confirm which version retail-hq is actually running.

DODIL MCP tools called
git_branch_listgit_check_listignite_app_get
Agent

main is at 91c620ea. Two checks on that SHA: ci/build — running (step 'Set up job' passed in 41s), and dodil-ci/deploy-config — errored, with detail 'invalid .dodil/deploy.yaml: apps[0]: unknown field replicas, expected one of name, image, port, resources, scaling at line 14 column 7'. Rollup: errored. Fix the key and push again. retail-hq is still on active_version 3, deployment_state deployed — the failed config check shipped nothing.

Budget 12–13 minutes for a cold buildpip install over a fresh layer cache is most of it, and a transient pypi ReadTimeoutError inside pip install is normal (the build survives on retry). Warm caches are much faster. This duration is exactly why the manifest being checked in and parsed as its own check matters: you find out the manifest is wrong from dodil-ci/deploy-config, with a line number, instead of from a human typing a deploy command wrong at minute 13.

When the checks go green, the rest is unremarkable — which is the goal. The same-org pull is instant (Successfully pulled image ... in 419ms in the validated run), and with the numeric USER from Step 3 the pod is admitted and starts answering the probe: "GET /healthz HTTP/1.1" 200 OK.

IMPORTANT

A green dodil-ci/deploy does not always mean a new version was published. We watched an empty commit report the deploy check as passed while the app stayed on its previous version. The check reports that the apply ran, not that anything changed — confirm the thing you actually care about with dodil ignite app get $APP --output json and read active_version.

TIP

Do not use this loop to debug your app. Thirteen minutes per iteration is a fine price for shipping and an absurd one for finding a typo in a SQL string. The app is a plain process: export the same BUCKET and the same DODIL_SERVICE_ACCOUNT_ID/DODIL_SERVICE_ACCOUNT_SECRET you put in the secret store, run uvicorn app:api --port 8080 --reload on your laptop, and iterate against the same DataK3 bucket in seconds — the data plane is a wire endpoint, so local and deployed are the same code talking to the same rows. Use CD to ship, not to debug. We learned this the expensive way.

Step 7 — The URL, and auth the app doesn't write

The app's public FQDN is <app>-<org>-<port>.ignite.dodil.cloud — for this build, https://retail-hq-ihdiash-8080.ignite.dodil.cloud. It is on the app record as public_urls. (Note the shape: the port is part of the hostname, one URL per exposed port.)

Because .dodil/deploy.yaml set public_invoke: false and user_pool: retail-vision, that URL requires an end-user login — and the gateway performs it. Your image ships no OIDC client, no session store, no password handling:

curl -s -i https://retail-hq-ihdiash-8080.ignite.dodil.cloud/
# HTTP/2 401
# {"error":"end-user login required","login_url":"/.dodil/auth/login?return_to=%2F"}

Open it in a browser and you land on the pool's hosted, pool-branded sign-in page. On the way back the gateway sets an AEAD-sealed, host-only session cookie and, from then on, injects the identity as headers your app simply trusts — any inbound copy of these is stripped first, so they cannot be forged:

  • X-Dodil-User — JSON with sub, email, connection, app_roles
  • X-Dodil-User-Jwt — the verified token, carrying the expanded permissions claim
  • X-Dodil-Auth-Sourcepool or platform, i.e. which kind of caller this is

That last header is the point people miss: Ignite auth is additive. Attaching a pool does not lock out your service accounts — machine callers keep working as platform while humans arrive as pool. Which of the two is required is config, not code.

One registration is manual today and it is the difference between "healthy deploy" and "healthy deploy, broken login": the pool's redirect_uris must contain the app's callback.

# settings set REPLACES the whole settings object — re-pass everything you rely on.
dodil appid settings set "$POOL" \
  --redirect-uris https://retail-hq-ihdiash-8080.ignite.dodil.cloud/.dodil/auth/callback

Miss it and login fails with redirect_uri is not in the pool's allowlist (settings.redirect_uris).

IMPORTANT

Serve the UI and the API from one image, on one port. The gateway's session cookie is host-only. Split the SPA and the API across two FQDNs and the cookie never reaches the API — leaving you to put a token in the browser, or worse, ship a machine credential to the client. One image, one port, one origin. If you must split, make the UI app proxy to the API (BFF); never hand the browser a credential.

The full identity model — pools, connections, roles → permissions, tenants, and the case where your app should verify the pool JWT itself (running off-Ignite, or called machine-to-machine, with iss and aud both mandatory) — is its own tutorial: App authentication on DODIL.

Step 8 — Versions and rollback

Every CD apply publishes a numbered version, and because the manifest deploys the immutable {sha} tag, each version is pinned to an exact commit's image. Rollback re-points the active version — no rebuild, no waiting 13 minutes to undo a bad afternoon.

You

List the versions of retail-hq, roll it back to version 3, and confirm which version is active now.

DODIL MCP tools called
ignite_version_listignite_version_rollbackignite_app_getignite_app_logs
Agent

retail-hq has 4 versions; version 4 is active (runtime image:prebuilt). Rolled back to version 3 — active_version is now 3, deployment_state deployed, public_urls retail-hq-ihdiash-8080.ignite.dodil.cloud. Pod logs show the older image serving GET /healthz 200 OK.

The forward fix is the same motion as everything else: push a commit. CD builds it, applies it, and adds version 5.

Failure modes, and what they actually look like

Each of these was hit for real during the runs that validated this post. Nearly all of them fail after something that looked like success — which is why they're worth memorising.

SymptomCauseFix
remote: forbidden: insufficient scope on git pushkey holds git.ci (clone + report checks only)issue with --role git.editor --service git
forbidden: insufficient scope setting a secretgit.editor can push code but not write deploy-time credentialsuse a token with git.admin
dodil-ci/deploy-config errored with a line number.dodil/deploy.yaml is deny_unknown_fields — typo'd key, bad require_checks, or a ${{ … }} that isn't a plain secret referencefix the named line, push again
deploy fails naming a secret you never setan env value references a secret that isn't in the repo's storedodil git secret set <repo> <NAME> (Step 5)
app 500s with a KeyError on an env vara declared env replaces the app's whole map — an out-of-band --env value is gonedeclare every runtime variable in the manifest
container has runAsNonRoot and image will run as rootafter a successful pullno USER, or a username rather than a uidUSER 10001 (numeric), plus chown of the app dir
deploy rejected naming admin-registry.dodil.iothe image: ref points at another org's projectdeploy your own org's image (same-org pulls need no secret at all)
dodil-ci/deploy green but the app is on the old versionthe check reports that the apply ran, not that it changed anythingread active_version from dodil ignite app get
healthy pod, but login dead-ends: redirect_uri is not in the pool's allowlistpool redirect_uris missing the app callbackdodil appid settings set $POOL --redirect-uris https://<fqdn>/.dodil/auth/callback — re-pass every setting
one-off auth_unavailable with retry_after_secs: 1gateway briefly couldn't fetch the pool JWKStransient; it clears itself — the JWKS endpoint stayed 200 throughout

Test

Validated live against DODIL on 2026-09-05, org ihdiash, repo IHDIASH/retail-vision-app, app ihdiash/retail-hq. This is the sequence, and the evidence:

# 1) source + push credential (Step 1)
dodil git repo list --org-name IHDIASH
# -> retail-vision-app  main  private  01a06e47-93dc-7143-aad9-baa2ccdc3c9c
 
# 2) the build check on the SHA (Steps 2+6)
dodil git check list retail-vision-app "$SHA" --org-name IHDIASH
# -> ci/build passed in 12m48s; image pushed to
#    registry.dodil.io/ihdiash/retail-vision:<sha> and :latest
 
# 3) the manifest is parsed as its own check (Step 4)
# -> a typo'd key errors dodil-ci/deploy-config with the offending line, and
#    per-app deploy failures now appear in the check detail as "app <name>: <error>"
# -> tier: medium parses (small | medium | large | xlarge; standard = deprecated alias)
# -> a bare user_pool: retail-vision resolves against org IHDIASH/ihdiash either way
 
# 4) the runtime credential (Step 5)
# -> PUT https://git.dodil.io/api/v1/repos/<repo_id>/ci-secrets/RETAIL_SA -> 204 (git.admin;
#    git.editor -> forbidden: insufficient scope)
# -> deployed with DODIL_SERVICE_ACCOUNT_SECRET: "${{ secrets.RETAIL_SA }}" in env:
#    the app's runtime value resolved (looks unresolved: False), nothing in git
 
# 5) pull + admission (Steps 2+3)
# -> same-org pull with NO registry secret: "Successfully pulled image ... in 419ms"
# -> with USER 10001: pod admitted; uvicorn logs "GET /healthz HTTP/1.1" 200 OK
# -> without it: "container has runAsNonRoot and image will run as root"
 
# 6) the gated URL (Step 7)
dodil ignite app get retail-hq --output json
# -> public_urls ["retail-hq-ihdiash-8080.ignite.dodil.cloud"], public_invoke false,
#    user_pool "ihdiash/retail-vision", active_version 4, deployment_state deployed
curl -s https://retail-hq-ihdiash-8080.ignite.dodil.cloud/
# -> 401 {"error":"end-user login required","login_url":"/.dodil/auth/login?return_to=%2F"}
# -> in a browser: redirected to the pool's hosted sign-in, branded with the pool name;
#    after allowlisting the callback the flow proceeds

What was verified, precisely: the git repo + git.editor push, the ci/build workflow run and the image landing in registry.dodil.io with both tags, the strict manifest parse, a same-org image pull with no registry secret of any kind (deployed by ref alone, pod pulled, started and served its URL) plus the explanatory rejection of a foreign-org ref, the non-root admission failure and its numeric-USER fix, a manifest secret reference resolving into the app's runtime env, the git.admin-vs-git.editor split on the ci-secrets API, the app serving /healthz 200, the unauthenticated 401 with its login_url, and the hosted-login redirect including the redirect_uris allowlist failure. dodil ignite version list / rollback were confirmed to exist and are unchanged from the previous edition of this post; the four-version history on retail-hq is real, but the rollback transition was not re-exercised in this run.

Tear down when you're done — nothing lingers or bills:

dodil ignite app delete "$APP" --yes
dodil registry repo delete "$IMAGE" --org-name "$ORG_LC" --yes
dodil git repo delete "$REPO" --org-name "$ORG"   # takes its CI secrets with it
dodil auth apikey list            # find the push key lookup...
dodil auth apikey revoke <lookup> # ...and revoke it

One-shot

Ship a container to a login-gated DODIL endpoint the supported, git-driven way:
1. dodil git repo create $REPO --org-name $ORG. Mint the push credential with
   `dodil auth apikey issue --service git --role git.editor` (git.ci CANNOT push).
   Push with principalId:secret as HTTP Basic.
2. Add .github/workflows/ci.yml: actions/checkout@v4, docker/login-action@v3 with
   registry=secrets.DODIL_REGISTRY_URL, username=ci, password=secrets.DODIL_REGISTRY_TOKEN
   (both injected by the runner — configure nothing), then docker/build-push-action@v6
   pushing :$SHA and :latest, annotating org.opencontainers.image.source/.revision and
   io.dodil.git.repo_id from the injected DODIL_GIT_REPO_URL/SHA/REPO_ID env.
3. Dockerfile: create uid 10001, chown the app dir, and declare USER 10001 NUMERICALLY —
   Ignite admits pods with runAsNonRoot and a username is not resolvable from image config.
   Bind 0.0.0.0:8080.
4. Add .dodil/deploy.yaml (version 1, deny_unknown_fields): create_if_missing: true; one app
   with name/image "{registry}/<org>/<image>:{sha}"/port/resources.tier (small|medium|large|
   xlarge, standard = deprecated alias)/scaling/env/health, labels+group (tri-state:
   absent=no change, {}=clear, map=REPLACE), user_pool as a BARE pool name (org-normalized;
   absent=no change, ""=detach), public_invoke:false (create-time only); when.branches
   [main] and when.require_checks all. reserved_capacity is not in the schema — set it with
   `ignite app update --reserved`.
5. Runtime credentials: an env value may be "${{ secrets.NAME }}", resolved by the CD runner
   from the repo's CI secret store at deploy time (never in git, masked in logs, missing
   secret = loud deploy failure; any other ${{ }} form = parse error). A DECLARED env
   REPLACES the app's env map every deploy, so declare EVERY runtime variable. Set the
   secret with `PUT https://git.dodil.io/api/v1/repos/<repo_id>/ci-secrets/<NAME>` body
   {"value":"..."} -> 204, Bearer auth, role git.admin (git.editor is forbidden here);
   `GET .../ci-secrets` lists names only. A `dodil git secret` CLI is coming.
6. git push. Watch `dodil git check list $REPO $SHA` — ci/build (~12-13 min cold), then
   dodil-ci/deploy-config (only if the manifest is bad) and dodil-ci/deploy. A green deploy
   check does NOT prove a new version shipped — confirm active_version with
   `dodil ignite app get $APP`. Same-org image pulls need NO registry secret; only an
   external/other-org registry needs `ignite secret create --type registry` +
   `--registry-secret-ref <bare-name>`. Debug app logic locally against the same bucket
   (BUCKET + DODIL_SERVICE_ACCOUNT_ID/SECRET + uvicorn), not through the 13-minute loop.
7. URL is https://<app>-<org>-<port>.ignite.dodil.cloud. With user_pool + public_invoke
   false it returns 401 {"error":"end-user login required","login_url":...} and the GATEWAY
   runs the login; the app reads X-Dodil-User / X-Dodil-User-Jwt / X-Dodil-Auth-Source and
   ships no auth code. Allowlist https://<fqdn>/.dodil/auth/callback with
   `dodil appid settings set $POOL --redirect-uris ...` (it REPLACES all settings).
8. Roll back with `dodil ignite version list $APP` + `dodil ignite version rollback $APP <n>`
   — a re-point, not a rebuild. Roll forward by pushing a commit.

Conclusion

The supported last mile on DODIL is two files in your repo. .github/workflows/ci.yml builds and pushes the image with a credential you never configure; .dodil/deploy.yaml declares the app — including a reference to the runtime credential, so the one value that could never be committed is the one value the file doesn't contain — and CD applies it on every green build. Source, CI, registry, compute, secrets and end-user identity all live in one org, under one dodil auth context, on sovereign EMEA hardware — and the deploy command is git push.

The three things worth carrying away: git.editor, not git.ci (and git.admin for secrets); a numeric USER, or the pod dies after a perfect pull; and user_pool on the ingress, so business-user login is a config key rather than a fifth vendor and a month of work.

Now point it at a real engine. Take the CRM master-data core or the semantic agent cache, add these two files, and the handler you built shown-as-code becomes a service with a URL, a login page, a version history and a rollback button.