Point DNS at the server
An
A record for the hostname you will put in DOMAIN, TTL 300 until you are happy.Create a deploy user and install Docker
As root on the fresh box:
Get the code onto the server
As
deploy:Write .env on the server
This file never enters git and never goes into the image. It is the single source of
truth for production secrets.At minimum:Run
.env
docker-compose.yml reads DOMAIN and POSTGRES_PASSWORD itself for
interpolation, and passes the whole file into the containers with env_file: .env.
So POSTGRES_PASSWORD is written once and DATABASE_URL is built from it.Do not set FLASK_CONFIG, PORT, WEB_CONCURRENCY, DATABASE_URL, REDIS_URL
or JOB_BACKEND here. Compose sets those on the container, and a duplicate in
.env only creates a way for them to disagree.feather env check to see which keys your config.py actually reads and which
are still missing. It exits non-zero when a key that has no fallback is unset, so it
works as a CI or deploy gate.Deploy
docker compose logs -f caddy shows whether issuance worked. When the script prints
Healthy. Deploy complete. the site is live over HTTPS.What deploy.sh does
docker compose build— every service. Web and worker are separate images even though they share a Dockerfile; building onlywebleaves the worker running last week’s code.docker compose up -d db redis— dependencies first, so the migration step has something to talk to.docker compose run --rm web feather db upgrade— migrations, once, in a throwaway container built from the new image. The old containers are still serving while this runs, so a migration that fails leaves the site up.docker compose up -d --remove-orphans— swap the containers.- Wait for health. It polls Docker’s health status for the
webcontainer, which runscurl /health, which checks the database, for up to 120 seconds. Healthy: prune dangling images and exit 0. Unhealthy or timed out: dump the last 50 log lines and exit 1.
The ordering is the whole point. Migrations run exactly once, against the image about
to serve traffic, before any long-lived container starts. Two web containers can never
race on the same Alembic upgrade, and a migration failure is not a partial deploy.
Rolling back
There is no automatic rollback. If a deploy goes bad, check out the previous commit and run./deploy/deploy.sh again. Migrations already applied are not reverted, so undo
those deliberately with feather db downgrade.
Confirm what the database is actually at:
TLS and the proxy headers
Caddy obtains and renews Let’s Encrypt certificates automatically for every hostname indeploy/Caddyfile. There is nothing to run, no certbot cron, no renewal to forget.
deploy/Caddyfile
{$DOMAIN} comes from .env via compose. To serve www as well, add a second block. A
certificate is issued for each hostname that appears in the file.
Two load-bearing headers
X-Forwarded-Proto and Host
X-Forwarded-Proto and Host
Caddy sets these by default and Feather’s ProxyFix reads them. Without them Google
OAuth builds an
http:// redirect URI and secure session cookies are dropped on
every response.X-Real-IP
X-Real-IP
Caddy does not set this one, which is why the generated config does. Rate
limiting and any geo logic read it, and when it goes missing they fail silently
rather than loudly.