Skip to main content
Any Ubuntu 24.04 box works, whether Hetzner, DigitalOcean or Vultr. Four vCPU and 8 GB is comfortable for an app and its database.

Point DNS at the server

An A record for the hostname you will put in DOMAIN, TTL 300 until you are happy.
Do this first. Caddy cannot issue a certificate before DNS resolves to the machine.

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:
.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.Leave FLASK_CONFIG, PORT, WEB_CONCURRENCY, DATABASE_URL, REDIS_URL and JOB_BACKEND out of this file. Compose sets them on the container, and a duplicate here only creates a way for the two to disagree.
Run feather env check to see which keys your config.py reads and which are still missing. It exits non-zero when a key with no fallback is unset, so it works as a CI or deploy gate.

Deploy

Watch the first run. Caddy requests a certificate while the app starts, and 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

In order:
  1. docker compose build builds every service. Web and worker are separate images despite sharing a Dockerfile, and building only web leaves the worker on last week’s code.
  2. docker compose up -d db redis starts dependencies, so the migration step has something to talk to.
  3. docker compose run --rm web feather db upgrade runs migrations once, in a throwaway container built from the new image. The old containers keep serving while this happens, so a failed migration leaves the site up.
  4. docker compose up -d --remove-orphans swaps the containers.
  5. Wait for health. It polls Docker’s health status for the web container, which runs curl /health, which checks the database, for up to 120 seconds. On success it prunes dangling images and exits 0. On failure or timeout it dumps the last 50 log lines and exits 1.
The ordering is what matters. 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 failed migration is never a partial deploy.

Rolling back

There is no automatic rollback. If a deploy goes wrong, 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. To confirm where the database actually is:

TLS and the proxy headers

Caddy obtains and renews Let’s Encrypt certificates for every hostname in deploy/Caddyfile. There is nothing to run, no certbot cron, and 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 in the file.

Two load-bearing headers

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.
Caddy does not set this one, which is why the generated config does. Rate limiting and any geo logic read it, and they fail silently when it is missing.
After editing the Caddyfile, validate before reloading: