Documentation
Everything this instance can do, from the browser, from CI and from a terminal. The label names, paths and hostnames below are this installation's real configuration, not placeholders.
Concepts
deployer does one job: CI (or you) pushes an image, deployer pulls it and safely replaces the running container. It does not build images and it does not manage DNS — it deploys what already exists.
What makes a service deployable
Two things, both in the target's own compose file:
services:
myapp:
image: node:22-alpine
labels:
- "deploy-agent.enable=true" # this label IS the authorization
- The service declares the opt-in label
deploy-agent.enablein its compose file. An image label does not count — Docker merges image labels into container labels, so any image could otherwise opt itself in. - The compose file lives under an allowed root:
/Users/keytype/Projects/Personal/deployer/.deployer/deployer-stacks,/Users/keytype/Projects/Personal/deployer/.deployer/deployer-adopted.
There is no list of services to maintain: everything else is discovered
from the container's com.docker.compose.* labels at deploy
time.
Safety rules, always on
- Only the image tag or digest may change on a deploy. The repository must match what the container already runs, so a deploy can never point a service at somebody else's image.
- One deploy per stack at a time, enforced with a lock.
- A failed deploy is rolled back to the previous image automatically.
Only when the rollback also fails is the service left down —
and that is reported as a distinct error (
503). - The last 3 images per app are kept for rollback; older ones are pruned after a successful deploy.
Web UI
Applications
Everything registered, and the entry point for deploys. Open an application to get one form that covers all three operations — they are the same action with a different image argument:
- Deploy — enter a new tag CI has pushed.
- Redeploy — enter the tag already running; it is
re-pulled, which matters when the tag is mutable, like
:latest. - Roll back — pick a previous image from this app's own history. A rollback is an ordinary deploy of an older tag, so the tag must still exist locally (see the retention rule above).
A deploy runs in the request and takes on the order of 30–60 seconds for a small app. The spinner is honest; the page will answer when it finishes.
Discover
Every compose service on the host, and for each one that cannot be deployed, the reason: no opt-in label, a compose file outside the allowed roots, or a container running a bare image ID with no repository to compare against. Sync registers every deployable service as an application. It is idempotent — running it twice adds nothing twice.
Adopt
Containers started with docker run have no compose labels at
all, so Discover cannot see them. Adopt lists them, and converts one into a
compose-managed application in three steps:
- Review — deployer generates the compose file it would
write. Only settings that differ from the image are emitted, so image
defaults keep tracking the image. Anything it cannot express (a
--device, a memory limit) is listed as a warning rather than dropped silently. - Apply — the original container is renamed and stopped, Compose brings up the replacement, and only then is the original removed. If anything fails in between, the rename is undone and the container is running exactly as before.
- The app is registered and from then on has the same single deploy path
as everything else. The original
docker inspectis saved next to the generated file (.adopted-from.json) so what the container was stays auditable.
Named and anonymous volumes are preserved — anonymous ones are pinned by
their hash and declared external, which keeps the data.
Proxy
Add a hostname and a container port to an application on its page, then review and apply on the Proxy page. Two rules worth knowing:
- The configuration shown is derived state: rendered from the route rows on every apply, never edited in place. Hand edits to the generated file are lost on the next apply — edit routes in the UI instead.
- Adding or deleting a route does not change the proxy by itself. Apply on the Proxy page when the set of routes is ready — applying is what writes the config, reloads the proxy, and joins the proxy to any Docker networks it needs to reach the apps.
A routed app needs no published ports: the proxy reaches it over the Docker network by service name. With the caddy driver (the default), naming a hostname is the entire certificate story — Let's Encrypt issuance and renewal are automatic. An unrouted hostname gets a 404, and a 502 means the route exists but the upstream is unreachable.
Deploy API
The machine surface — what CI calls. For every endpoint, request bodies,
and response shapes, see the interactive
Swagger UI (/api/openapi.json for
the raw spec).
GET /api/health
No authentication. Returns {"status": "ok"}.
POST /api/deploy
Authenticated with an HMAC-SHA256 signature over the raw request
body, sent in the X-Signature-256 header as
sha256=<hex>. The signing secret is
DEPLOYER_WEBHOOK_SECRET. Sign the exact bytes you send — a
re-serialized copy of the JSON can differ byte for byte and will not
verify.
| field | required | meaning |
|---|---|---|
service | yes | The compose service name to deploy. |
image | yes | The exact image to deploy. Same repository as the running container; only the tag or digest may differ. |
project | no | Compose project name, to disambiguate when two projects share a service name. |
compose_file | no | Path to the compose file, for the same purpose. |
Response
{
"status": "deployed",
"service": "myapp",
"project": "myproject",
"container": "myproject-myapp-1",
"image": "node:22.9.0-alpine",
"previous_image": "node:22-alpine",
"pruned_images": [],
"deployment_id": 42
}
Note what changed between previous_image and
image: the tag only. The repository (node) must
stay the same — that is the rule a 400 enforces.
Status codes
| code | meaning |
|---|---|
200 | Deployed. The response names what is running now and what ran before. |
400 | Bad request — malformed body, invalid image reference, or the image's repository differs from what the container runs. |
401 | Bad or missing signature. Checked before anything touches Docker. |
403 | The service has no deploy-agent.enable label in its compose file. |
409 | Another deploy of that stack is in flight, or the service was asked to deploy itself. |
422 | The compose file is outside the allowed roots. |
500 | The deploy failed and the previous image was restored — the response names the tag it rolled back to. |
503 | The deploy failed and the rollback failed. The service is down. This is the one worth paging for. |
CI integration
From any shell
SECRET=… # the DEPLOYER_WEBHOOK_SECRET value
BODY='{"service":"myapp","image":"node:22.9.0-alpine"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -fsS -X POST https://cicddeploy.duckdns.org/api/deploy \
-H "Content-Type: application/json" \
-H "X-Signature-256: sha256=$SIG" \
-d "$BODY"
printf '%s' rather than echo: echo appends a
newline, the signature would cover it, and the request body would not — a
permanent, baffling 401.
GitHub Actions
Add the secret as DEPLOYER_WEBHOOK_SECRET in the repository's
Actions secrets, then append a deploy job after the image is pushed:
deploy:
needs: build # whatever job pushes the image
runs-on: ubuntu-latest
steps:
- name: Deploy
env:
SECRET: ${{ secrets.DEPLOYER_WEBHOOK_SECRET }}
run: |
IMAGE="ghcr.io/${{ github.repository }}:sha-${GITHUB_SHA::7}"
BODY="{\"service\":\"myapp\",\"image\":\"$IMAGE\"}"
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -fsS -X POST https://cicddeploy.duckdns.org/api/deploy \
-H "Content-Type: application/json" \
-H "X-Signature-256: sha256=$SIG" \
-d "$BODY"
The deploy endpoint must be reachable from the runner — either this instance's public hostname through the proxy, or a self-hosted runner on the same network.
Terminal
Run from the directory holding deployer's own compose file. Every command
goes through docker compose exec deployer flask ….
Accounts
There is deliberately no sign-up page: an account is created by someone who already has shell on the host, which is the right bar for a tool that can replace any container on the machine.
docker compose exec deployer flask create-user NAME # prompts for a password (min 12 chars) docker compose exec deployer flask passwd NAME # change a password docker compose exec deployer flask users # list accounts and last sign-in docker compose exec deployer flask delete-user NAME # refuses to delete the last account
Discovery and registration
docker compose exec deployer flask scan # every compose service, deployable or why not docker compose exec deployer flask register # register all deployable services; idempotent
scan is the Discover page for a terminal — useful exactly
when reaching the UI is the thing that is broken.
Proxy
docker compose exec deployer flask proxy-render # print the config that would be applied docker compose exec deployer flask proxy-apply # write it and reload the proxy docker compose exec deployer flask proxy-init # write a starting config if none exists; never overwrites
Backup
All state is one SQLite file on the deployer_data volume plus
the generated stacks under
/Users/keytype/Projects/Personal/deployer/.deployer/deployer-adopted:
docker compose cp deployer:/data/deployer.sqlite ./deployer-backup.sqlite
Configuration
Set in .env next to deployer's compose file; a restart picks
up changes. Empty or whitespace-only values count as unset.
| variable | meaning |
|---|---|
DEPLOYER_WEBHOOK_SECRET | Required. Signs the deploy API. Generate with openssl rand -hex 32. |
DEPLOYER_SECRET_KEY | Session signing. Falls back to the webhook secret; set its own value on anything shared. |
STACKS_ROOT | Required. Directory holding the compose stacks this instance may deploy. Mounted read-only. |
ADOPT_ROOT | Required. Where adoption writes generated compose files — the only writable path. |
DEPLOYER_PORT | Host port for the UI and API, bound to loopback only. Default 8090. |
DEPLOYER_ENABLE_LABEL | The opt-in label. Currently deploy-agent.enable. Only one label is honoured at a time. |
PROXY_DRIVER | caddy (default, automatic HTTPS) or nginx. Currently caddy. |
PROXY_HTTP_PORT / PROXY_HTTPS_PORT | Host ports the proxy listens on. 80/443 in production; defaults 8081/8443 to avoid colliding with an existing proxy. |
PROXY_HTTP_ALT_PORT | A second host port mapped to the proxy's port 80, for routers that forward public 80 to a different internal port. |
PROXY_ACME_EMAIL | Sent to Let's Encrypt. Optional, but without it there is no warning when a renewal starts failing. |
DEPLOYER_HOSTNAME | This instance's own public hostname, if it should be reachable through the proxy. Currently cicddeploy.duckdns.org. |
DEPLOYER_PROXY_TLS | Whether deployer's own route requests a certificate. Set false until the hostname's port-80 reachability is proven — Let's Encrypt rate-limits failed validations. |
DEPLOYER_IMAGES_TO_KEEP | Rollback depth: how many images per app are kept. Currently 3. |
DEPLOYER_PULL_TIMEOUT etc. | Timeouts in seconds: PULL (600), UP (300), WAIT (120), QUERY (60). |
Troubleshooting
| symptom | usual cause and fix |
|---|---|
401 on every API call |
The signature does not cover the exact bytes sent. Sign the raw body
with printf '%s' (never echo), and check the
secret for a trailing newline from a copy-paste — .env
values are stripped, CI secret UIs are not always. |
403 not opted in |
The label must be in the service's compose file — a label
baked into the image does not count. Run flask scan; it
names the exact reason per service. |
422 compose file not allowed |
The stack lives outside the allowed roots. Move it under one of them
or extend DEPLOYER_ALLOWED_ROOTS. |
| 502 through the proxy, app is healthy | The proxy and the app usually share no Docker network — Compose puts each stack on its own. Apply on the Proxy page: it joins the proxy to the networks it needs and says which. |
| 404 through the proxy | No route is configured for that hostname — deliberate, so a DNS record pointed at the wrong host does not look healthy. Check the route exists and was applied. |
| Deploy button seems stuck | Deploys run in the request and take 30–60 s (pull + recreate + health wait). If the spinner is showing, it is working. |
| Locked out of the UI | Accounts are managed only from the host:
docker compose exec deployer flask create-user NAME. |
| Adoption is disabled | ADOPT_ROOT is unset or not writable. It must also be
listed in the allowed roots, or the generated stack could not be
deployed afterwards. |
Scripting the operator API
/api/deploy above is CI's endpoint and uses an HMAC signature.
Everything else — listing apps, adopting a container, adding a route,
applying the proxy — is the operator API and takes an API token.
docker compose exec deployer flask token-create ops ci
The token is printed once and stored only as a hash. Then:
curl -H "Authorization: Bearer dpl_..." https://cicddeploy.duckdns.org/api/apps
A token needs no CSRF token: browsers never attach an
Authorization header on their own, so a token-authenticated
request cannot be forged from another site. A browser session is different —
it is sent automatically, which is exactly what CSRF protects against, so
session requests that change something must carry
X-CSRFToken.
In Swagger, use Authorize and paste a token — "Try it out" then works on POSTs, which it cannot do with a session cookie.
flask tokens # list, with last-used flask token-revoke ID # immediate, and independent of the password