Skip to main content
Everything so far has run on your laptop. This part puts it on the internet properly: your domain, a real certificate, a database that gets backed up, and a deploy you trigger with one command or a merge to 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
Check whether your app already has the deployment files:
If that errors, add them. It never overwrites an existing file, so it’s safe in a project you’ve been working in.

Step 1: What you’re deploying

Eight files. Read them once now and every later step makes sense.
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.
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, so feather dev keeps Vite’s hot reload.
The ports and credentials match the DATABASE_URL and REDIS_URL already in your .env. Your data now lives in the container’s volume, so run the migrations against it:
Two variations worth knowing. Bring up one service alone:
And throw the local data away when a migration experiment goes wrong:
While you’re here, build the production image and audit it:
Fix what security-check finds now, on your laptop, rather than at 11pm on a server.

Step 3: Prepare the server

DNS first. Add an A 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.
Then set the box up, over SSH as root:
Now set PermitRootLogin no and PasswordAuthentication no in /etc/ssh/sshd_config, then systemctl restart ssh.
Open a second terminal and confirm ssh deploy@<ip> works before you close the first one. Getting this wrong locks you out of your own server.

Step 4: Code and secrets on the server

As deploy:
The production .env is the single source of truth for secrets. It’s git-ignored, never baked into the image, and never leaves the server.
.env
Some of that needs explaining. 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.
Don’t put FLASK_CONFIG, PORT, WEB_CONCURRENCY, DATABASE_URL, REDIS_URL or JOB_BACKEND here. Compose sets them on the container, and a copy in .env is one more place for them to disagree.
That lists the keys your config.py actually reads and which are missing.

Step 5: Deploy

It takes a few minutes the first time, while Docker pulls base images and builds Node dependencies. Watch for 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.
Open https://kanban.example.com. If the certificate isn’t there yet:
Caddy says plainly what Let’s Encrypt told it. Nearly always it’s DNS. Deploying again, after you push a change:
When a deploy goes wrong, there’s no automatic rollback. Check out the previous commit and deploy again. Migrations that already applied are not undone by that, so reverse them deliberately:
And check what the database thinks it’s at:

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 in deploy/Caddyfile.
deploy/Caddyfile
To serve www too, add a block. Each hostname in the file gets its own certificate.
That 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:
Run it once by hand first:
Then test a restore, because a backup you’ve never restored is a hypothesis:
A dump on the same disk as the database is not a backup. Add a step that copies it elsewhere, and encrypt it on the way out:
Everything that matters is in Postgres and GCS. The containers and Redis are disposable. The dumps are not.

Step 8: Deploy from GitHub Actions

Deploying by SSH is fine. Deploying on every merge to main is better, because then nobody has to remember.
.github/workflows/deploy.yml
Two repository secrets cover it: 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.
Running feather db upgrade against an empty Postgres in CI is the cheapest migration test there is. It catches a chain that no longer applies before that chain reaches production.

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:
The workflow’s 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 server
SECRET_KEY is a real random value, not the scaffolded placeholder
docker compose exec web printenv FLASK_CONFIG prints production
TRUSTED_HOSTS lists every hostname you serve
OAUTH_CALLBACK_URL is https://, exact, and matches Google Cloud Console
JOB_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 git
feather env check reports nothing missing
deploy/backup.sh is in cron, and you’ve restored one dump
An uptime monitor points at https://kanban.example.com/health
Server snapshots or backups enabled at your provider too

Step 10: Day-to-day operations

For a one-off task, use a throwaway container so you don’t disturb the running one:
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.
Keep the assistant off your production server unless you’ve decided otherwise deliberately. Having it prepare commands you run yourself gives you the same speed with a review step in between, and the deploy script is the piece that applies migrations.

Checkpoint

  • https://your-domain serves the app over a valid certificate
  • Signing in with Google works, which proves the proxy headers are right
  • curl https://your-domain/health returns healthy with a database check
  • ./deploy/deploy.sh completes and prints that it’s healthy
  • A push to main triggers the workflow, and it deploys after tests pass
  • deploy/backup.sh produces a dump, and you’ve restored one
  • feather security-check --env-file .env passes 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 .env on the server, where CI never sees it
  • feather env check and feather security-check as 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/logs records 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.