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.ymlwithact→ an image inregistry.dodil.io→.dodil/deploy.yamlCD 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 whatgit.ciactually 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.yamlv1 schema — including the tri-statelabelsanduser_poolkeys that quietly replace or detach things if you get them wrong. - How a manifest
envvalue references a repo CI secret, so the app's service-account secret reaches the runtime without ever living in git — and why a declaredenvnow 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_poolturns a public FQDN into a login-gated one without a line of app code.
Prerequisites
- The
dodilCLI (dodil auth login) and an agent connected over MCP (Claude Code, Cursor, VS Code, Codex). giton 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 withremote: forbidden: insufficient scope. It is the role the runner holds, not 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.
git_repo_create→auth_apikey_issue→git_clone-urlRepo 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.
dodil git repo create "$REPO" --org-name "$ORG" \
--description "Retail vision app — git-driven CI/CD demo"
# -> repo_id 01a06e47-93dc-7143-aad9-baa2ccdc3c9c, default main, visibility private
# The push credential. --service MUST match the role's plane: git.* -> --service git.
dodil auth apikey issue --name "$REPO-push" --service git --role git.editor --output json
# -> { "lookup": "...", "principalId": "...", "secret": "dk_..." } <- secret prints ONCE
export GIT_USER=... # principalId from above
export GIT_PASS=dk_... # secret from above
dodil git clone-url "$REPO" --org-name "$ORG"
# -> http: https://git.dodil.io/IHDIASH/retail-vision-app.gitPush 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 againstTIP
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 var | what it is | why you want it |
|---|---|---|
DODIL_GIT_SHA | the commit being built | org.opencontainers.image.revision |
DODIL_GIT_REPO_URL | the repo's id-form clone URL | org.opencontainers.image.source |
DODIL_GIT_REPO_ID | the repo's opaque id | io.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 rootIgnite admits pods with runAsNonRoot. Two consequences:
- A root image is rejected. Default
python:*/node:*images run as root. - A username is not enough.
USER appstill fails — the kubelet reads the image config, whereUSER appis an unresolved string; it cannot look up/etc/passwdinside an image it hasn't started. The uid must be numeric in the finalUSER.
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 write | what happens |
|---|---|
| key absent | never 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-vision | attaches 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:
- Put it in
env:— a live credential committed to git, in a repo your whole team clones. - 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 aKeyError, 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_SAwithout 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 thedodil-ci/deploy-configcheck. 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_SAThere 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.
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.
git_branch_list→git_check_list→ignite_app_getmain 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.
export SHA=$(git rev-parse HEAD)
dodil git branch list "$REPO" --org-name "$ORG" # confirm main is at your SHA
dodil git check list "$REPO" "$SHA" --org-name "$ORG"
# rollup: running
# NAME STATE DETAIL
# ci/build running running
#
# ...and ~12-13 minutes later, on a cold build:
# rollup: passed
# ci/build passed 12m48s
# dodil-ci/deploy passed retail-hq -> active_version 1
dodil git check list "$REPO" "$SHA" --org-name "$ORG" --output json # per-step timeline
dodil ignite app get "$APP" --output json # ...and what actually shippedBudget 12–13 minutes for a cold build — pip 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 withsub,email,connection,app_rolesX-Dodil-User-Jwt— the verified token, carrying the expandedpermissionsclaimX-Dodil-Auth-Source—poolorplatform, 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/callbackMiss 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.
List the versions of retail-hq, roll it back to version 3, and confirm which version is active now.
ignite_version_list→ignite_version_rollback→ignite_app_get→ignite_app_logsretail-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.
dodil ignite version list "$APP" # each version + its pinned image_ref
dodil ignite version get "$APP" 3 --output json # inspect one before committing to it
dodil ignite version rollback "$APP" 3 # re-point, not rebuild
dodil ignite app get "$APP" --output json # active_version: 3
dodil ignite app logs "$APP" --limit 30 # pod stdout/stderr — the HTTP error surfaceThe 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.
| Symptom | Cause | Fix |
|---|---|---|
remote: forbidden: insufficient scope on git push | key holds git.ci (clone + report checks only) | issue with --role git.editor --service git |
forbidden: insufficient scope setting a secret | git.editor can push code but not write deploy-time credentials | use 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 reference | fix the named line, push again |
| deploy fails naming a secret you never set | an env value references a secret that isn't in the repo's store | dodil git secret set <repo> <NAME> (Step 5) |
app 500s with a KeyError on an env var | a declared env replaces the app's whole map — an out-of-band --env value is gone | declare every runtime variable in the manifest |
container has runAsNonRoot and image will run as root — after a successful pull | no USER, or a username rather than a uid | USER 10001 (numeric), plus chown of the app dir |
deploy rejected naming admin-registry.dodil.io | the image: ref points at another org's project | deploy your own org's image (same-org pulls need no secret at all) |
dodil-ci/deploy green but the app is on the old version | the check reports that the apply ran, not that it changed anything | read active_version from dodil ignite app get |
healthy pod, but login dead-ends: redirect_uri is not in the pool's allowlist | pool redirect_uris missing the app callback | dodil appid settings set $POOL --redirect-uris https://<fqdn>/.dodil/auth/callback — re-pass every setting |
one-off auth_unavailable with retry_after_secs: 1 | gateway briefly couldn't fetch the pool JWKS | transient; 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 proceedsWhat 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 itOne-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.