main.
The whole thing runs on one machine for about €7 a month. The app, Postgres, Redis and TLS
termination all share the box.
You’ll use: feather docker init, multi-stage builds, docker-compose.dev.yml,
one-shot migrations, health-gated deploys, Caddy, deploy/backup.sh and GitHub Actions.
What you need
- A working app from part 4 or part 5
- A domain whose DNS you control
- A VPS you can SSH into as root. Hetzner, DigitalOcean and Vultr all work. Two vCPU and 4 GB is enough to start
- Docker locally, which step 2 uses
Step 1: What you’re deploying
Eight files. Read them once now and every later step makes sense.The frontend builds in its own stage
The frontend builds in its own stage
Node compiles Tailwind and bundles your islands, then only the built
static/dist is
copied into the runtime image. There’s no Node in the container that serves traffic.That stage also copies Feather’s own templates out of the Python layer, because
Tailwind has to scan them to find the class names the framework components use. Without
it, every button() and modal() renders unstyled in production and nowhere else.Migrations don't run when the container starts
Migrations don't run when the container starts
Look at the last line of the Dockerfile:No
feather db upgrade. That’s deliberate, and step 5 explains why.Step 2: Run the dependencies locally
Before touching a server, move your local Postgres and Redis into containers. The app stays on your machine, sofeather dev keeps Vite’s hot reload.
DATABASE_URL and REDIS_URL already in your .env.
Your data now lives in the container’s volume, so run the migrations against it:
Step 3: Prepare the server
DNS first. Add anA record for the hostname you’ll use, pointing at the server’s IP,
TTL 300 until you’re confident. Caddy asks Let’s Encrypt for a certificate the moment it
starts, and Let’s Encrypt checks that the name resolves to the machine asking.
PermitRootLogin no and PasswordAuthentication no in /etc/ssh/sshd_config,
then systemctl restart ssh.
Step 4: Code and secrets on the server
Asdeploy:
.env is the single source of truth for secrets. It’s git-ignored, never
baked into the image, and never leaves the server.
.env
DOMAIN and POSTGRES_PASSWORD are read by docker-compose.yml itself, not just by the
app. Compose interpolates them into the Caddy service and into DATABASE_URL, which is
why you write the database password exactly once and never assemble a connection string by
hand.
OAUTH_CALLBACK_URL and TRUSTED_HOSTS are what stop a forged Host header from
redirecting your users’ sign-in somewhere else. Without the callback URL, Feather derives
the redirect URI from whatever Host the client sent. Set both, and add the same callback
string to your OAuth client in Google Cloud Console.
config.py actually reads and which are missing.
Step 5: Deploy
Healthy. Deploy complete.
Here’s what it did, in order, and why the order matters:
1
docker compose build
Builds every service. Web and worker are separate images even though they come from
one Dockerfile. Build only
web and your worker keeps running last week’s code, which
is a bug you’ll spend an afternoon on.2
docker compose up -d db redis
Dependencies first, so there’s a database to migrate.
3
docker compose run --rm web feather db upgrade
The migrations, once, in a throwaway container built from the new image, while the
old containers still serve traffic. A migration that fails here stops the deploy and
leaves your site up.This is why the Dockerfile’s
CMD doesn’t run migrations. If it did, and you ever ran
two web containers, both would start feather db upgrade at the same moment against
the same database. Alembic doesn’t arbitrate that. One process applies half a migration
while the other applies the same one, and you spend the evening repairing
alembic_version by hand.4
docker compose up -d --remove-orphans
Now swap the containers.
5
Wait for health
The script polls Docker’s health status for the
web container for up to 120 seconds.
That healthcheck runs curl /health, which checks the database, so “healthy” means the
app can serve a request rather than that Python is running. Unhealthy or timed out, and
it dumps the last 50 log lines and exits non-zero.https://kanban.example.com. If the certificate isn’t there yet:
Step 6: TLS and the proxy headers
You didn’t run certbot and there’s no renewal cron. Caddy obtains and renews certificates for every hostname indeploy/Caddyfile.
deploy/Caddyfile
www too, add a block. Each hostname in the file gets its own certificate.
header_up X-Real-IP line isn’t decoration. Caddy sets Host and
X-Forwarded-Proto by default, which is what Feather’s ProxyFix reads. Without them your
OAuth flow builds an http:// redirect URI and your secure session cookies get dropped on
every response. But Caddy does not set X-Real-IP, and rate limiting reads it. Delete
that line and your rate limits quietly start counting every request as coming from the
same address.
Step 7: Backups
deploy/backup.sh dumps Postgres in pg_dump custom format and keeps 14 days. Put it in
cron on the host, as deploy, with crontab -e:
Step 8: Deploy from GitHub Actions
Deploying by SSH is fine. Deploying on every merge tomain is better, because then
nobody has to remember.
.github/workflows/deploy.yml
SSH_KEY for the deploy@ private key, and SSH_HOST.
The server builds its own images, so CI needs no registry and holds no application
secrets.
Hardening the CI key
A key that can run any command is a key that can read your.env. Lock it to one command
in the server’s ~deploy/.ssh/authorized_keys:
ssh argument is then ignored, and the key can’t open a shell.
Step 9: The pre-launch checklist
feather security-check --env-file .env passes on the serverSECRET_KEY is a real random value, not the scaffolded placeholderdocker compose exec web printenv FLASK_CONFIG prints productionTRUSTED_HOSTS lists every hostname you serveOAUTH_CALLBACK_URL is https://, exact, and matches Google Cloud ConsoleJOB_SERIALIZER=json, which the generated compose file sets. pickle will execute whatever it finds on the queue if Redis is ever compromised.env is chmod 600 and not in gitfeather env check reports nothing missingdeploy/backup.sh is in cron, and you’ve restored one dumpAn uptime monitor points at
https://kanban.example.com/healthServer snapshots or backups enabled at your provider too
Step 10: Day-to-day operations
Docker’s default log driver grows until the disk fills. The generated compose file caps
every service at
max-size: 20m and max-file: 3. If you add a service of your own,
anchor logging: *default-logging onto it too.Prompt Claude
Deployment differs from the earlier parts, because much of it happens on a server rather than in your repository. Split it accordingly.- Repository work
- Pre-launch review
Checkpoint
https://your-domainserves the app over a valid certificate- Signing in with Google works, which proves the proxy headers are right
curl https://your-domain/healthreturns healthy with a database check./deploy/deploy.shcompletes and prints that it’s healthy- A push to
maintriggers the workflow, and it deploys after tests pass deploy/backup.shproduces a dump, and you’ve restored onefeather security-check --env-file .envpasses on the server
What you learned
feather docker init, and that it never overwrites without--force- Multi-stage builds that leave Node out of the runtime image
- Running dependencies in containers while the app stays on your machine
- Running migrations exactly once, before the swap, so two containers can’t race
- Health-gated deploys, where healthy means the database is reachable
- Caddy’s automatic certificates, and the proxy headers OAuth depends on
- Keeping
.envon the server, where CI never sees it feather env checkandfeather security-checkas pre-launch gates
Where to go next
- A staging host. A second A record, a second checkout, a second compose project name, and the same scripts.
- Log aggregation. The app writes JSON logs in production. Ship them somewhere you can search.
- Error alerting.
/admin/logsrecords exceptions. Add a notification when the rate spikes. - Off-site backups. The piece step 7 leaves as an exercise, and the one you’ll be glad you did.