Production deployment
This page covers deploying Miam to a non-local environment — typically a single VM or a small VPS — using the same docker-compose.yml that drives local development, with hardened configuration.
The architecture is unchanged: nginx (in the frontend container) serves the built React app on port 80 and proxies /api/* to the backend service. Postgres runs in its own container with a persistent volume.
Target topology
┌───────────────────────────────────┐
│ Reverse proxy (TLS) │
│ Caddy / Traefik / nginx host │
└────────────────┬──────────────────┘
│ https://your-domain.com
▼
┌───────────────────────────────────────────────────────┐
│ Docker Compose │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ frontend │───►│ backend │───►│ db │ │
│ │ (nginx) │ │ (FastAPI) │ │ (Postgres) │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└───────────────────────────────────────────────────────┘
The frontend container already does the /api → backend:8000 proxy internally — your host-level reverse proxy only needs to terminate TLS and forward port 80 of the frontend container.
1. Prepare the server
Minimum requirements:
- Linux host with Docker + Docker Compose
- A DNS record pointing your domain to the server's public IP
- Open ports
80and443(for the reverse proxy) - 1 vCPU / 1 GB RAM is enough for a small instance
# On the server
git clone https://github.com/LouisStefanuto/miam.git
cd miam
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
2. Production env vars
Edit backend/.env:
# Strong, randomly generated values — do NOT reuse the local defaults
POSTGRES_USER=miam
POSTGRES_PASSWORD=<long-random-password>
POSTGRES_DB=recipes
DATABASE_URL=postgresql+psycopg2://miam:<long-random-password>@db:5432/recipes
JWT_SECRET_KEY=<output of: python -c "import secrets; print(secrets.token_urlsafe(48))">
GOOGLE_CLIENT_ID=<your prod client id>.apps.googleusercontent.com
# CRITICAL: lock CORS to your real frontend origin
CORS_ORIGINS=["https://your-domain.com"]
Edit frontend/.env:
VITE_GOOGLE_CLIENT_ID=<your prod client id>.apps.googleusercontent.com
Things to double-check before going live
JWT_SECRET_KEYis unique per environment and has never been committed.CORS_ORIGINSlists only your real frontend origin(s).POSTGRES_PASSWORDis not the defaultpostgres.- The
.envfiles are mode600and owned by the user running Docker.
3. Google OAuth for production
Reopen your OAuth client in the Google Cloud Console:
- Under Authorized JavaScript origins, add
https://your-domain.com(in addition to any local origins you already have). - In Google Auth Platform > Audience, click Publish app to leave Testing mode — otherwise only emails listed under Test users can sign in.
- Some apps require Google's verification flow if you request sensitive scopes; Miam only uses
openid email profile, which is non-sensitive and does not need verification.
4. TLS termination
The JWT cookie is set with secure=True, so the app only works over HTTPS in non-local environments. Pick whichever reverse proxy you like — Caddy is the simplest because it does Let's Encrypt automatically:
# /etc/caddy/Caddyfile
your-domain.com {
reverse_proxy localhost:3000
}
Then map the frontend container to 127.0.0.1:3000 instead of 0.0.0.0:3000 — open the docker-compose.yml and change:
frontend:
ports:
- "127.0.0.1:3000:80" # only reachable from the local host (i.e. via Caddy)
Do the same for the backend and db services — they should not be reachable from the public internet directly.
5. Launch
docker compose up -d --build
-d runs the stack in the background. Tail logs to make sure migrations applied and the API booted:
docker compose logs -f backend
You should see:
Waiting for database to be ready...
Database is ready.
INFO [alembic.runtime.migration] Running upgrade -> ...
INFO Started server process
INFO Application startup complete.
INFO Uvicorn running on http://0.0.0.0:8000
Then verify in the browser at https://your-domain.com.
6. Backups
The database lives in the pgdata Docker volume. At minimum, schedule a daily dump:
# In a cron job on the host
cd /path/to/miam && make db-dump
That writes a timestamped dump_YYYYMMDD_HHMMSS.sql to the repo root. Ship it off-server to your usual backup target (S3, Backblaze, restic, etc.). Restoring is a regular psql < dump.sql after creating the empty database.
7. Updating
git pull
docker compose up -d --build
Migrations run automatically on backend startup. Pre-built images on Docker Hub are not published — the deployment builds from source on each update. If you want zero downtime, build images on a separate host (or in CI) and docker compose pull instead of --build.
Building images on a different architecture
The repo includes Makefile shortcuts for cross-platform builds:
make amd # build for linux/amd64
make arm # build for linux/arm64
Useful when you develop on Apple Silicon but deploy to an x86 VPS.
8. Operations checklist
| Concern | Where it lives |
|---|---|
| Container logs | docker compose logs <service> or Dozzle on port 9999 via make dozzle |
| Database backups | make db-dump + off-server sync (cron) |
| Secret rotation | Bump JWT_SECRET_KEY (logs everyone out), restart backend |
| Load testing | make loadtest against staging — never against prod |
| Resource usage | Dozzle, or docker stats |
| Health check | GET /api/ and GET /docs should both 200 |
See also Dev tasks for day-to-day operations.