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
  1. The service declares the opt-in label deploy-agent.enable in its compose file. An image label does not count — Docker merges image labels into container labels, so any image could otherwise opt itself in.
  2. 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:

  1. 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.
  2. 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.
  3. The app is registered and from then on has the same single deploy path as everything else. The original docker inspect is 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.

fieldrequiredmeaning
serviceyes The compose service name to deploy.
imageyes The exact image to deploy. Same repository as the running container; only the tag or digest may differ.
projectno Compose project name, to disambiguate when two projects share a service name.
compose_fileno 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

codemeaning
200Deployed. The response names what is running now and what ran before.
400Bad request — malformed body, invalid image reference, or the image's repository differs from what the container runs.
401Bad or missing signature. Checked before anything touches Docker.
403The service has no deploy-agent.enable label in its compose file.
409Another deploy of that stack is in flight, or the service was asked to deploy itself.
422The compose file is outside the allowed roots.
500The deploy failed and the previous image was restored — the response names the tag it rolled back to.
503The 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.

variablemeaning
DEPLOYER_WEBHOOK_SECRETRequired. Signs the deploy API. Generate with openssl rand -hex 32.
DEPLOYER_SECRET_KEYSession signing. Falls back to the webhook secret; set its own value on anything shared.
STACKS_ROOTRequired. Directory holding the compose stacks this instance may deploy. Mounted read-only.
ADOPT_ROOTRequired. Where adoption writes generated compose files — the only writable path.
DEPLOYER_PORTHost port for the UI and API, bound to loopback only. Default 8090.
DEPLOYER_ENABLE_LABELThe opt-in label. Currently deploy-agent.enable. Only one label is honoured at a time.
PROXY_DRIVERcaddy (default, automatic HTTPS) or nginx. Currently caddy.
PROXY_HTTP_PORT / PROXY_HTTPS_PORTHost ports the proxy listens on. 80/443 in production; defaults 8081/8443 to avoid colliding with an existing proxy.
PROXY_HTTP_ALT_PORTA second host port mapped to the proxy's port 80, for routers that forward public 80 to a different internal port.
PROXY_ACME_EMAILSent to Let's Encrypt. Optional, but without it there is no warning when a renewal starts failing.
DEPLOYER_HOSTNAMEThis instance's own public hostname, if it should be reachable through the proxy. Currently cicddeploy.duckdns.org.
DEPLOYER_PROXY_TLSWhether 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_KEEPRollback 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

symptomusual 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