Phase 1: Docker infrastructure optimization

- Add .dockerignore for backend and frontend (DC-1: eliminates node_modules/
  and .env from build context)
- Delete start.sh with --reload flag (DC-2: superseded by Dockerfile CMD)
- Create entrypoint.sh with exec uvicorn (DW-5: proper PID 1 signal handling)
- Pin base images to patch-level tags (DW-1: reproducible builds)
- Reorder Dockerfile: create appuser before COPY, use --chown (DW-2)
- Switch to npm ci for lockfile-enforced installs (DW-3)
- Add network segmentation: backend_net + frontend_net (DW-4: db unreachable
  from frontend container)
- Add deploy.resources limits to all services (DW-6: OOM protection)
- Refactor proxy-params.conf to include security headers, deduplicate from
  nginx.conf location blocks (DW-7)
- Add image/svg+xml to gzip_types (DS-1)
- Add wget healthcheck for frontend service (DS-2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-13 00:03:46 +08:00
parent a73bd17f47
commit dbad9c69b3
9 changed files with 143 additions and 43 deletions

43
backend/.dockerignore Normal file
View File

@ -0,0 +1,43 @@
# Version control
.git
.gitignore
# Python artifacts
__pycache__
*.pyc
*.pyo
*.egg-info
dist
build
.eggs
# Virtual environments
.venv
venv
env
# IDE
.vscode
.idea
# Environment files — never bake secrets into the image
.env
.env.*
# Tests
tests
pytest.ini
.pytest_cache
.coverage
htmlcov
# Documentation
*.md
LICENSE
# Dev scripts
start.sh
# Docker files (no need to copy into the image)
Dockerfile
docker-compose*.yaml

View File

@ -1,5 +1,5 @@
# ── Build stage: compile C extensions ────────────────────────────────── # ── Build stage: compile C extensions ──────────────────────────────────
FROM python:3.12-slim AS builder FROM python:3.12.9-slim-bookworm AS builder
WORKDIR /build WORKDIR /build
@ -11,24 +11,25 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Runtime stage: lean production image ─────────────────────────────── # ── Runtime stage: lean production image ───────────────────────────────
FROM python:3.12-slim FROM python:3.12.9-slim-bookworm
# Create non-root user first, then copy with correct ownership (DW-2)
RUN useradd -m -u 1000 appuser
WORKDIR /app WORKDIR /app
# Copy pre-built Python packages from builder # Copy pre-built Python packages from builder
COPY --from=builder /install /usr/local COPY --from=builder /install /usr/local
# Copy application code # Copy application code with correct ownership — avoids redundant chown layer
COPY . . COPY --chown=appuser:appuser . .
# Make entrypoint executable
RUN chmod +x entrypoint.sh
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser USER appuser
EXPOSE 8000 EXPOSE 8000
# Run migrations and start server # Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5)
# --no-server-header: suppresses uvicorn version disclosure ENTRYPOINT ["./entrypoint.sh"]
# --proxy-headers: reads X-Forwarded-Proto/For from reverse proxy so redirects use correct scheme
# --forwarded-allow-ips '*': trusts proxy headers from any IP (nginx is on Docker bridge network)
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header --proxy-headers --forwarded-allow-ips '*'"]

13
backend/entrypoint.sh Normal file
View File

@ -0,0 +1,13 @@
#!/bin/sh
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting uvicorn..."
exec uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--no-server-header \
--proxy-headers \
--forwarded-allow-ips '*'

View File

@ -1,9 +0,0 @@
#!/bin/bash
# Run database migrations
echo "Running database migrations..."
alembic upgrade head
# Start the FastAPI application
echo "Starting FastAPI application..."
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

View File

@ -5,11 +5,18 @@ services:
env_file: .env env_file: .env
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks:
- backend_net
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
backend: backend:
build: ./backend build: ./backend
@ -18,11 +25,19 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks:
- backend_net
- frontend_net
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
frontend: frontend:
build: ./frontend build: ./frontend
@ -32,6 +47,24 @@ services:
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
networks:
- frontend_net
healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
interval: 15s
timeout: 5s
retries: 3
deploy:
resources:
limits:
memory: 128M
cpus: "0.5"
volumes: volumes:
postgres_data: postgres_data:
networks:
backend_net:
driver: bridge
frontend_net:
driver: bridge

25
frontend/.dockerignore Normal file
View File

@ -0,0 +1,25 @@
# Dependencies — rebuilt inside the container from lockfile
node_modules
# Build output — rebuilt inside the container
dist
# Version control
.git
.gitignore
# Environment files
.env
.env.*
# IDE
.vscode
.idea
# Documentation
*.md
LICENSE
# Docker files
Dockerfile
docker-compose*.yaml

View File

@ -1,13 +1,13 @@
# Build stage # Build stage
FROM node:20-alpine AS build FROM node:20.18-alpine AS build
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies from lockfile (DW-3)
RUN npm install RUN npm ci
# Copy source files # Copy source files
COPY . . COPY . .
@ -16,7 +16,7 @@ COPY . .
RUN npm run build RUN npm run build
# Production stage — unprivileged nginx (runs as non-root, listens on 8080) # Production stage — unprivileged nginx (runs as non-root, listens on 8080)
FROM nginxinc/nginx-unprivileged:alpine FROM nginxinc/nginx-unprivileged:1.27-alpine
# Copy built files from build stage # Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html

View File

@ -41,7 +41,7 @@ server {
gzip on; gzip on;
gzip_vary on; gzip_vary on;
gzip_min_length 1024; gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json; gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml;
# Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04) # Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04)
location ~ /\.(?!well-known) { location ~ /\.(?!well-known) {
@ -122,28 +122,12 @@ server {
include /etc/nginx/proxy-params.conf; include /etc/nginx/proxy-params.conf;
} }
# API proxy # API proxy (catch-all for non-rate-limited endpoints)
location /api { location /api {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
include /etc/nginx/proxy-params.conf;
# PT-L01: Prevent browser caching of authenticated API responses
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
# Security headers (must be repeated nginx add_header in a location block
# overrides server-level add_header directives, so all headers must be explicit)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
} }
# SPA fallback - serve index.html for all routes # SPA fallback - serve index.html for all routes

View File

@ -4,3 +4,13 @@ proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto; proxy_set_header X-Forwarded-Proto $forwarded_proto;
# Security headers (repeated per location — nginx add_header in a location block
# overrides server-level directives, so all headers must be explicit)
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;