Deploying FastAPI, PostgreSQL and Nginx with Docker Compose
Docker Compose gets dismissed as a development-only tool, which undersells it. For a single-server deployment it is a genuinely good production choice: one file describes the whole stack, restarts are atomic, and rolling back means checking out the previous image tag. What it does not give you is multi-host scheduling — and most services never need that.
The gap between a tutorial compose file and one you would run in production is mostly about four things: image size, startup ordering, data durability, and not running everything as root. This walks through all four.
A multi-stage Dockerfile
A naive Python image is often 1.2 GB, most of which is a compiler toolchain that was needed to build wheels and is dead weight at runtime. A multi-stage build compiles in one image and copies only the result into a clean one.
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt
FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 curl \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app.main:app", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--workers", "2", \
"--bind", "0.0.0.0:8000", \
"--access-logfile", "-"]The runtime stage installs libpq5, the runtime library, rather than libpq-dev, the headers. That distinction is what lets you drop build-essential entirely and typically takes the image from over a gigabyte to around 200 MB.
PYTHONUNBUFFERED=1 matters more than it looks. Without it, Python buffers stdout when it is not a terminal, so your application logs appear in docker logs in delayed chunks — or not at all if the container is killed. It is the single most common reason a containerised Python app appears to log nothing.
Copy requirements.txt and install dependencies before copying the application source. Docker caches layers in order, so a code change then rebuilds only the final COPY instead of reinstalling every package. This turns a three-minute rebuild into a five-second one.
Add a .dockerignore, or your build context includes .git, the virtualenv, and every node_modules directory — slowing builds and occasionally baking secrets into an image layer.
.git
.venv
__pycache__
*.pyc
.env
.pytest_cache
node_modules
tests/The compose file
Here is the whole stack. Note what is absent: no ports mapping on the database, no plaintext passwords, and no depends_on without a condition.
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- backend
api:
build:
context: .
target: runtime
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
networks:
- backend
- frontend
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/letsencrypt:ro
depends_on:
api:
condition: service_healthy
networks:
- frontend
volumes:
pgdata:
secrets:
db_password:
file: ./secrets/db_password.txt
networks:
frontend:
backend:
internal: trueWhy depends_on alone is not enough
A bare depends_on only waits for the container to start, not for the process inside it to be ready. PostgreSQL takes several seconds to initialise on first run, so your API container starts, fails to connect, and exits — then restarts into the same race. With restart: unless-stopped this eventually converges, which is why the bug is often never noticed and never fixed.
condition: service_healthy makes the dependency real. Compose waits for the healthcheck to pass before starting the dependent service. The start_period is the piece people omit: during that window a failing check does not count toward the retry limit, which stops a slow-starting container from being declared unhealthy while it is still legitimately booting.
| depends_on form | Waits for |
|---|---|
| depends_on: [db] | Container started — the process may not accept connections yet |
| condition: service_started | Same as above, written explicitly |
| condition: service_healthy | Healthcheck passing — what you almost always want |
| condition: service_completed_successfully | Container exited 0 — for migration or seed jobs |
Your application still needs connection retry logic. A healthy database at container start does not mean a healthy database an hour later during a failover or restart. depends_on solves boot ordering, not resilience.
Networks, and not exposing the database
The compose file defines two networks. The api container joins both; the db container joins only backend, which is marked internal: true. That means the database has no route to or from the outside world at all, and only containers on the backend network can reach it.
Equally important is the absence of a ports entry on db. Publishing 5432:5432 is the norm in development tutorials, and on a public EC2 instance it exposes PostgreSQL to the entire internet — Docker writes iptables rules that bypass UFW, so a firewall you thought was blocking it is not.
# Development only — bind to localhost, never 0.0.0.0
ports:
- "127.0.0.1:5432:5432"That explicit 127.0.0.1 prefix is the difference between a port your psql client can reach over an SSH tunnel and a port anyone can reach. If you need database access from your laptop, tunnel it rather than publishing it.
ssh -N -L 5432:localhost:5432 ubuntu@api.example.comVolumes: what survives, and what does not
pgdata is a named volume, which is what keeps your database when you run docker compose down and rebuild. A bind mount would work too, but named volumes are managed by Docker, get correct permissions automatically, and are considerably faster on Docker Desktop.
The critical distinction is between down and down -v. The first stops and removes containers, leaving volumes intact. The second removes the volumes too — which deletes your database.
| Command | Containers | Named volumes | Images |
|---|---|---|---|
| docker compose stop | Stopped | Kept | Kept |
| docker compose down | Removed | Kept | Kept |
| docker compose down -v | Removed | DELETED | Kept |
| docker compose down --rmi all | Removed | Kept | Removed |
Back up a named volume by running a throwaway container that mounts it alongside a host directory:
docker run --rm \
-v myapp_pgdata:/data:ro \
-v "$PWD":/backup \
alpine tar czf /backup/pgdata-$(date -u +%Y%m%d).tar.gz -C /data .For a database, prefer a logical dump over a filesystem copy of a running data directory — a tarball of live PostgreSQL files is not guaranteed to be consistent. Run pg_dump inside the container instead:
docker compose exec -T db \
pg_dump -U myapp --format=custom myapp > backup-$(date -u +%Y%m%d).dumpRunning migrations
Migrations should not run in the API container entrypoint. With two or more API replicas, every replica races to apply the same migration on startup. Run them as a discrete step instead:
docker compose run --rm api alembic upgrade head
docker compose up -d --no-deps --build apiOr model it as a one-shot service that the API waits on, using service_completed_successfully:
migrate:
build:
context: .
target: runtime
command: ["alembic", "upgrade", "head"]
env_file: .env
depends_on:
db:
condition: service_healthy
networks:
- backend
restart: "no"
api:
depends_on:
migrate:
condition: service_completed_successfullyDeploying and rolling back
Build images in CI and tag them with the commit SHA rather than building on the server. A server build competes with your running application for CPU, and a tag like latest gives you nothing to roll back to.
# On the server
export APP_TAG=sha-9f3c1ab
docker compose pull
docker compose up -d --no-deps api
docker compose ps
# Rollback is the previous tag
export APP_TAG=sha-4d81e02
docker compose up -d --no-deps apiReference the variable in the compose file with a default, so a missing value fails loudly instead of silently deploying something unexpected:
api:
image: ghcr.io/you/myapp:${APP_TAG:?APP_TAG must be set}Finally, cap your logs. Docker's default json-file driver has no size limit, and a chatty application will eventually fill the disk — which takes down every container on the host at once.
# /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}Is Docker Compose acceptable in production, or do I need Kubernetes?
Compose is a perfectly reasonable production tool for a single host, and a single host serves a great deal of traffic. Kubernetes earns its complexity when you need scheduling across many machines, automatic rescheduling on node failure, or independent scaling of many services. Adopting it for a three-container stack costs far more than it returns.
Should PostgreSQL run in a container in production?
It works and many teams do it, provided the data lives on a named volume or a bind mount on a dedicated EBS volume, and backups run outside the container. That said, a managed database removes an entire category of operational work, and it is usually the first thing worth outsourcing.
Why does my container log nothing?
Nearly always output buffering. Set PYTHONUNBUFFERED=1 for Python, or make sure your logger writes to stdout rather than a file inside the container. A log file inside a container is written to the writable layer and disappears when the container is replaced.
How do I handle TLS certificates in a containerised setup?
Run Certbot in its own container writing to a shared volume that Nginx mounts read-only, and reload Nginx after renewal. Alternatively use Caddy as the reverse proxy, which obtains and renews certificates automatically with no extra moving parts.
Why can the internet reach my database even though UFW blocks 5432?
Because Docker inserts its own iptables rules ahead of the ones UFW manages, so a published port bypasses the firewall entirely. The fix is not to publish the port: remove the ports entry and keep the database on an internal network, or bind it explicitly to 127.0.0.1.