Minimal, opinionated rolling deploys for Docker Compose + Traefik stacks.
Replaces Kamal's useful subset — rolling deploys, health checks, automatic failure recovery — without its baggage.
curl -fsSL https://deploy.flowcanon.com/install | shThis installs the flow-deploy binary to ~/.local/bin. Start a new login shell if it's not in your PATH.
flow-deploy upgrade replaces the running binary with the latest release. It
downloads the new binary next to the old one, checks that it runs, then renames
it over the old one, so a failed or bad download leaves the old binary in place.
It needs write access to the binary's directory.
The install is per user: each SSH user has its own ~/.local/bin/flow-deploy.
deploy-action's upgrade: true upgrades the binary of the user it deploys
as, and no one else's. Projects deployed as other users, or without
upgrade: true, keep their version until upgraded.
Upgrading from 0.3.0 runs 0.3.0's upgrade code, which doesn't check that the
download runs, and exits with a traceback if the binary's directory isn't
writable. Either failure makes deploy-action's upgrade step fail, so the
deploy doesn't run. The first deploy after upgrading needs no migration:
.git/deploy-tag is written by that deploy, and containers an older version
started are restored by the tag they were started from.
1. Label your services in docker-compose.yml:
services:
web:
image: ghcr.io/myorg/myapp:${DEPLOY_TAG:-latest}
labels:
deploy.role: app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5The tag is both a git ref (checked out on the host) and the image tag: every app
service's image must end in :${DEPLOY_TAG}, usually :${DEPLOY_TAG:-latest}
(see Image Tags).
Every deploy.role=app service must have a healthcheck defined in compose (Dockerfile HEALTHCHECK is not detected). To opt out, set deploy.healthcheck.skip=true. Services without a deploy.role label are ignored.
2. Deploy:
flow-deploy deploy --tag v1.4.0 # a release tag, or a commit SHA[12:34:56] ── deploy ──────────────────────────────
[12:34:56] tag: v1.4.0
[12:34:58] ▸ web
[12:34:58] pulling ghcr.io/myorg/myapp:v1.4.0...
[12:35:02] pulled (3.8s)
[12:35:02] starting new container...
[12:35:05] waiting for health check (timeout: 120s)...
[12:35:08] healthy (6.2s)
[12:35:08] draining old container (a1b2c3d, 30s timeout)...
[12:35:11] ✓ web deployed (16.1s)
[12:35:11] ── complete (16.1s) ─────────────────────
That's it. If the health check fails, the old container keeps serving traffic and the deploy exits 1.
For each deploy.role=app service, in order:
- Pull the new image
- Scale to 2 — start a new container alongside the old one
- Health check — poll the new container until healthy or timeout
- Cutover — if healthy, gracefully drain the old container and scale back to 1
- Abort — if unhealthy, remove the new container. Old container is untouched.
| Label | Behavior |
|---|---|
deploy.role=app |
Rolled during deploy. Health-checked. Rolled back on failure. |
deploy.role=accessory |
Never touched during deploy. |
| (no label) | Ignored entirely. |
All configuration is via Docker labels on your services:
| Label | Default | Description |
|---|---|---|
deploy.role |
— | app or accessory (required) |
deploy.order |
100 |
Deploy order. Lower goes first. |
deploy.drain |
30 |
Seconds to wait for graceful shutdown |
deploy.healthcheck.timeout |
120 |
Seconds to wait for healthy |
deploy.healthcheck.poll |
2 |
Seconds between health polls |
deploy.healthcheck.skip |
false |
Skip healthcheck validation and waiting |
deploy.strategy |
rolling |
rolling or drain-first (see below) |
Rolling deploys briefly run old and new containers side by side. That's right for web servers, but wrong for queue workers and schedulers. An old worker that's still draining keeps running old code after the new web container has migrated the database.
Label those services deploy.strategy: drain-first and the deploy runs in three
phases:
- Drain. Drain-first services stop in reverse
deploy.order. Each one pulls its new image, then stops its running containers. Stopping isdocker stop --time <deploy.drain>: SIGTERM first, so Celery-style workers finish their current tasks and stop taking new ones, then SIGKILL when the drain expires. Nothing rolls until every drain has finished. A container that had to be killed (exit 137, or the whole drain used up) gets a warning: its PID 1 probably isn't handling SIGTERM, typically a shell-formCMDor an entrypoint script that doesn'texecthe server. The same warning covers a rolling service's old container. - Roll. Rolling services deploy in
deploy.order, as usual. - Start. Drain-first services start in
deploy.orderon the new tag, and each must pass its health check.
So with web (100), api (200) and worker (300), the worker drains first,
then web and api roll, then the worker starts last, after the services it
depends on. The worker is down from the start of its drain until phase 3.
Queued work waits in the broker and the new worker picks it up.
A failed deploy leaves no trace. Whatever fails, every app service goes back to exactly what it ran before the deploy, with no manual cleanup:
- The failing rolling service keeps its old container; the new one is removed.
- Rolling services that already shipped earlier in the run are rolled back, in
reverse
deploy.order, with the same rolling deploy onto their previous image. - Every drain-first service comes back on its previous image at the same
replica count, in
deploy.order. That includes one that wouldn't stop, one that failed its own start, and one already healthy on the new tag: phase 3 attempts every start, and a failure anywhere sends them all back. A service that wasn't running before the deploy is left stopped. - The repo goes back to the previous commit, and the lock is released.
A rollback runs old code against whatever the new code already did (a migration, say), so this relies on the usual expand-then-contract discipline: old code keeps working against the new schema.
What "previous" means is read from the containers, and named by git. Before
changing anything, the deploy records the image each app service is running
(the most common one if its containers disagree, with a warning) and the tag
it was started from. A revert runs compose up with that tag in DEPLOY_TAG
when it's a git ref (a release tag or a commit SHA) that still names the
running image and isn't the tag being deployed (which this deploy pulls).
Otherwise (:latest, a tag re-pushed since, a digest, no tag, a redeploy of
the same tag)
it uses the SHA of the commit that was checked out before the deploy, so CI
must push every image under its full commit SHA (see Image Tags).
One case has no git name for the previous build: redeploying the commit that's
already running. If its image was rebuilt and re-pushed under the same tag, a
revert restores that commit as pulled now; the deploy warns when it sees this.
(Before this release, shipped services weren't rolled back at all.)
Nothing is tagged on the host, and nothing is written outside .git. If another compose project runs this
deploy's services in the same directory (a release that renamed the project
would start a second stack beside the first), the deploy stops before
changing anything.
A signal reverts the same way, and so does an unexpected error. The signals are SIGTERM (a timeout or shutdown), Ctrl-C (SIGINT), and SIGHUP (a hung-up terminal). Further signals are ignored until the revert finishes.
A signal that arrives while a drain-first container is stopping is held until that container has stopped and been removed, and the deploy then reverts. The docker CLI being interrupted doesn't interrupt the daemon's stop, so reverting immediately would find the container still "running" and leave it be, only to see it exit afterwards. Holding the signal costs at most the rest of that container's drain.
A cancelled CI job does not revert a remote deploy. deploy-action runs
ssh host "cd dir && flow-deploy deploy --tag X" with no pty. Without a pty,
sshd sends the remote process no signal when the client disconnects.
tests/integration/test_ci_cancel.py checks this against a real sshd. The
deploy runs to completion, or reverts on its own failure, exactly as if the job
were still watching. Its output to the closed session is discarded rather than
crashing it. Every run is also written to .git/flow-deploy.log on the host
(inside .git, so the tree stays clean; the last 20 runs are kept), and
flow-deploy status shows how the last one ended.
The deploy exits 1 when it failed and everything was put back. It exits 3
only when something couldn't be put back:
- the git restore failed
- a rollback or restore failed, including a restored service that doesn't pass its health check (a revert waits for what it put back)
- a failed service's own cleanup failed, such as an aborted new container that won't stop or be removed
Even then the lock is released. Exit 3 is the signal that someone needs to
look.
Rolling deploys otherwise behave as before. tests/test_rolling_parity.py
replays 15 rolling scenarios against a recording of the previous release: every
command, its environment, the exit code, and the lock. As before, a successful
cutover whose old-container cleanup fails still succeeds, with a warning: the
new container is healthy and serving.
The deliberate changes for rolling-only projects, each pinned by that test:
- A later service's failure rolls back services that already shipped. Previously they stayed on the new tag beside services on the old one.
- The deploy records each service's running image and restore tag first.
git fetch --tags --force origin, so release tags arrive and a re-pushed tag moves.compose configis rendered with the deploy'sDEPLOY_TAG.- Scale-back after a failed health check passes
--no-recreate. Previously it recreated the surviving old container on the new, failing image. - Scale-back is skipped when the container it should leave behind couldn't be stopped and removed: with two left it removes one, and could pick the wrong one. The cutover still succeeds with a warning, as before.
- A compose project name that differs from the running containers' is refused.
- A failed git restore exits
3and releases the lock. - A signal restores the repo before exiting
1. Previously it released the lock and exited without restoring.
Deploying rolling services with --service while leaving drain-first services
out prints a warning, since those keep running old code.
services:
worker:
labels:
deploy.role: app
deploy.strategy: drain-first
deploy.drain: "3660" # longest job + marginDEPLOY_TAG is normally the release version (v1.4.0), and a commit SHA works
just as well. The tag is used twice: git checkout --detach <tag> (lightweight
and annotated tags and SHAs all work; tags are fetched with --force, so a
re-pushed tag moves), and as the image tag compose pulls and runs. CI pushes the
image under every tag it might deploy by: the version, and always the full
commit SHA, which is also what a revert falls back to. The
compose file must name each app image with it:
image: ghcr.io/myorg/myapp:${DEPLOY_TAG:-latest}
# or, through .env: DOCKER_IMAGE=ghcr.io/myorg/myapp:${DEPLOY_TAG:-latest}
# image: ${DOCKER_IMAGE}A deploy warns when an app service's rendered image doesn't end in :<tag>.
Such a service keeps running whatever the compose file names.
The :-latest default is a trap: a person running docker compose up -d on the
host, with DEPLOY_TAG unset, silently moves every service to :latest. So
flow-deploy can tell you what's deployed, and run compose with it:
flow-deploy tag # prints the deployed tag, e.g. v1.4.0
flow-deploy compose up -d # compose with DEPLOY_TAG set to it
flow-deploy compose run --rm web python manage.py migrate
DEPLOY_TAG=v1.3.0 flow-deploy compose up -d # an explicit DEPLOY_TAG winsThe deployed tag is read from the running app containers when they all agree,
else from .git/deploy-tag (written by every successful whole-project deploy)
when nothing is running. Mixed or untagged images, or nothing to go on, are an
error ("set DEPLOY_TAG"), never a guess. flow-deploy status shows it too.
After a revert, the deployed tag is the release tag or SHA that was
restored. After a revert from :latest, a kept container still says latest
while recreated ones run the previous SHA; when they all run one image, the
SHA is the answer.
If the project has a compose wrapper (script/prod), make the wrapper default
DEPLOY_TAG the same way, so script/prod up -d is safe too. It must fail
closed: when flow-deploy tag can't tell, stop rather than fall through to
:latest.
if [ -z "${DEPLOY_TAG:-}" ]; then
DEPLOY_TAG=$(flow-deploy tag) || {
echo "script/prod: set DEPLOY_TAG (flow-deploy tag couldn't tell)" >&2
exit 1
}
export DEPLOY_TAG
fiflow-deploy sets DEPLOY_TAG itself on every compose call it makes: the
deploy tag, or a placeholder where the image doesn't matter or the tag isn't
known (flow-deploy tag itself, config in CI, status, exec, logs). So the wrapper never asks it,
never recurses, and never exits underneath it. Beyond that, a wrapper may set up
the environment, but must pass its arguments through unchanged, print nothing
extra on stdout, exit with compose's status, and never prompt or change files
(SPEC §3.1). On a fresh host with nothing
running, pass the tag: DEPLOY_TAG=v1.4.0 script/prod up -d. A --service
deploy that leaves some app services out leaves them on different tags, so it
clears the record and warns.
flow-deploy never touches accessories (deploy.role=accessory): no pulls, no
restarts. A change to one lands on the next manual up -d, which recreates
it, so plan such changes (data, downtime).
The common cause is .env: compose recreates every service that reads an
env_file when the file changes, postgres and redis included. Keep
accessories off the app's .env: drop env_file and list the few variables
they need under environment:, interpolated from .env so the values still
live in one place:
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
labels:
deploy.role: accessoryThen an app-env edit changes only the services that use those variables. For a
manual change, scope it to the service you mean: flow-deploy compose up -d --no-deps <service>.
For CI/CD orchestration across multiple hosts, declare topology in your compose file:
x-deploy:
host: app-1.example.com
user: deploy
port: 22
dir: /srv/myapp
services:
web:
labels:
deploy.role: app
worker:
labels:
deploy.role: app
deploy.host: worker-1.example.com # override per-serviceResolution order: GitHub Actions variable > per-service label > x-deploy default.
flow-deploy config (what deploy-action runs in CI) doesn't need the app's
environment. If compose rejects the project with CI's empty .env (an
image: ${DOCKER_IMAGE} with the variable unset, say), it retries without
interpolation and warns, naming the unset variables. ${VARS} in x-deploy
or deploy.* labels then stay literal, so set those in CI or use the
HOST_NAME/HOST_USER/SSH_PORT overrides.
See GitHub Actions Setup for CI/CD integration.
flow-deploy deploy [--tag TAG] [--service NAME] [--dry-run]
flow-deploy status
flow-deploy tag
flow-deploy compose ARGS...
flow-deploy exec SERVICE COMMAND...
flow-deploy logs SERVICE [-f] [-n LINES]
flow-deploy upgrade
The tool resolves the compose command in this order:
COMPOSE_COMMANDenvironment variablescript/prod(if executable)docker compose