diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..0e880dc --- /dev/null +++ b/backend/.dockerignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index c80a749..b58fc52 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # ── Build stage: compile C extensions ────────────────────────────────── -FROM python:3.12-slim AS builder +FROM python:3.12.9-slim-bookworm AS builder WORKDIR /build @@ -11,24 +11,25 @@ COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt # ── 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 # Copy pre-built Python packages from builder COPY --from=builder /install /usr/local -# Copy application code -COPY . . +# Copy application code with correct ownership — avoids redundant chown layer +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 EXPOSE 8000 -# Run migrations and start server -# --no-server-header: suppresses uvicorn version disclosure -# --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 '*'"] +# Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5) +ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..7edc73b --- /dev/null +++ b/backend/entrypoint.sh @@ -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 '*' diff --git a/backend/start.sh b/backend/start.sh deleted file mode 100644 index 770cff7..0000000 --- a/backend/start.sh +++ /dev/null @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 1b56b01..616f9af 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,11 +5,18 @@ services: env_file: .env volumes: - postgres_data:/var/lib/postgresql/data + networks: + - backend_net healthcheck: test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] interval: 5s timeout: 5s retries: 5 + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" backend: build: ./backend @@ -18,11 +25,19 @@ services: depends_on: db: condition: service_healthy + networks: + - backend_net + - frontend_net healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] interval: 10s timeout: 5s retries: 3 + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" frontend: build: ./frontend @@ -32,6 +47,24 @@ services: depends_on: backend: 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: postgres_data: + +networks: + backend_net: + driver: bridge + frontend_net: + driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b800409 --- /dev/null +++ b/frontend/.dockerignore @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ef60a12..88d4008 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,13 +1,13 @@ # Build stage -FROM node:20-alpine AS build +FROM node:20.18-alpine AS build WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies -RUN npm install +# Install dependencies from lockfile (DW-3) +RUN npm ci # Copy source files COPY . . @@ -16,7 +16,7 @@ COPY . . RUN npm run build # 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 --from=build /app/dist /usr/share/nginx/html diff --git a/frontend/nginx.conf b/frontend/nginx.conf index c3936cc..36a506d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -41,7 +41,7 @@ server { gzip on; gzip_vary on; 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) location ~ /\.(?!well-known) { @@ -122,28 +122,12 @@ server { include /etc/nginx/proxy-params.conf; } - # API proxy + # API proxy (catch-all for non-rate-limited endpoints) location /api { - proxy_pass http://backend:8000; - proxy_http_version 1.1; proxy_set_header Upgrade $http_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; - - # 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; + include /etc/nginx/proxy-params.conf; } # SPA fallback - serve index.html for all routes diff --git a/frontend/proxy-params.conf b/frontend/proxy-params.conf index 4b18310..7ff2efb 100644 --- a/frontend/proxy-params.conf +++ b/frontend/proxy-params.conf @@ -4,3 +4,13 @@ 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; + +# 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;