Docker — Containerising FastAPI and React

Docker packages an application and all its dependencies — Python version, libraries, system packages — into an immutable image that runs identically on any machine with Docker installed. The developer laptop, the CI server, and the production VPS all run the exact same image. This eliminates an entire category of deployment bugs: dependency version mismatches, missing system libraries, and OS-specific behaviour. For the blog application, we need three images: FastAPI, the React Vite build (served by Nginx), and the PostgreSQL database (official image, no Dockerfile needed).

FastAPI Dockerfile

# Dockerfile.backend — multi-stage build
# Stage 1: build dependencies (creates a clean wheel cache)
# Stage 2: production runtime (only copies what is needed)
# Dockerfile.backend
FROM python:3.12-slim AS builder

WORKDIR /build
COPY requirements.txt .
RUN pip install --upgrade pip \
 && pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

# ── Production stage ───────────────────────────────────────────────────────────
FROM python:3.12-slim AS production

# Non-root user for security
RUN addgroup --system app && adduser --system --ingroup app app

WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links /wheels /wheels/*.whl \
 && rm -rf /wheels

# Copy application code
COPY --chown=app:app . .

USER app

# Health check endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"

EXPOSE 8000
CMD ["uvicorn", "app.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--workers", "2", \
     "--no-access-log"]
Note: Running the application as a non-root user (USER app) is a security best practice. If an attacker exploits a vulnerability in the application, they are limited to the permissions of the app user — they cannot install packages, write to system directories, or escalate to root. The default Docker behaviour runs as root, which means a compromised container can potentially escape to the host with root privileges. Always run production containers as a non-root user.
Tip: The multi-stage build reduces the final image size significantly. The builder stage installs build tools (compilers, headers) needed to create Python wheels. The production stage only copies the pre-built wheels and installs them without any build tools. A typical FastAPI image shrinks from ~800MB (with all build dependencies) to ~150MB (production only). Smaller images mean faster pulls, less storage cost, and a smaller attack surface.
Warning: Never COPY . . without a .dockerignore file — you will inadvertently copy .env files with production secrets, .git history, node_modules, test fixtures, and other files that bloat the image or leak sensitive data. Create a .dockerignore that excludes: .env*, .git, __pycache__, *.pyc, .pytest_cache, htmlcov, node_modules, and any other development-only artefacts.

React + Nginx Dockerfile

# Dockerfile.frontend — Vite build → Nginx serve
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci --prefer-offline

COPY . .
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build   # outputs to /app/dist

# ── Nginx production stage ─────────────────────────────────────────────────────
FROM nginx:1.27-alpine AS production

# Copy Vite build output to Nginx's default serve directory
COPY --from=builder /app/dist /usr/share/nginx/html

# Copy custom Nginx config (see next lesson)
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

docker-compose.yml

# docker-compose.yml — production stack
services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER:     ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB:       ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"]
      interval: 10s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru

  backend:
    build:
      context: .
      dockerfile: Dockerfile.backend
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    env_file: .env.production
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB}
      REDIS_URL:    redis://redis:6379

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.frontend
      args:
        VITE_API_BASE_URL: /api
    restart: unless-stopped
    depends_on: [backend]

  nginx:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
    depends_on: [frontend, backend]

volumes:
  postgres_data:

Common Mistakes

Mistake 1 — No .dockerignore (secrets and bloat in image)

❌ Wrong — COPY . . without .dockerignore copies .env files with real passwords.

✅ Correct — create .dockerignore with at minimum: .env*, .git, node_modules, __pycache__.

Mistake 2 — Running as root in production container

❌ Wrong — default Docker user is root.

✅ Correct — create a non-root user and switch with USER app before CMD.

🧠 Test Yourself

Why does the React Dockerfile use a two-stage build (Node.js build stage + Nginx serve stage) instead of just an Nginx image serving a pre-built dist folder?