···11+# Node modules — never copy local node_modules into Docker builds.
22+# pnpm install inside the container creates its own virtual store and symlinks.
33+# If local node_modules are copied in, they overwrite container symlinks with
44+# paths pointing to the local machine's pnpm store (which doesn't exist in container).
55+node_modules
66+**/node_modules
77+88+# Compiled output — Docker builds TypeScript from source
99+**/dist
1010+1111+# Git
1212+.git
1313+.gitignore
1414+1515+# Secrets — never include in build context
1616+docker/.env
1717+docker/.env.*
1818+.env
1919+.env.*
2020+*.local
2121+2222+# IDE / OS
2323+.vscode
2424+.claude
2525+*.log
+46
docker/.env.example
···11+# ═══════════════════════════════════════════════════════
22+# ATlast Docker Environment Variables — TEMPLATE
33+# ═══════════════════════════════════════════════════════
44+# Copy this file to docker/.env and fill in real values.
55+# NEVER commit docker/.env to git.
66+#
77+# Generate secrets with: bash scripts/generate-secrets.sh
88+# ═══════════════════════════════════════════════════════
99+1010+# ── Database ──────────────────────────────────────────
1111+# Strong random password for the PostgreSQL 'atlast' user.
1212+# Generate: node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))"
1313+DB_PASSWORD=change-me-use-a-strong-password
1414+1515+# ── OAuth (Bluesky Login) ─────────────────────────────
1616+# EC private key in PEM format, used to sign OAuth tokens.
1717+# Generate: openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt
1818+# The key spans multiple lines — use \n to represent newlines in a .env file.
1919+OAUTH_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...your key here...\n-----END PRIVATE KEY-----
2020+2121+# ── Token Encryption ──────────────────────────────────
2222+# REQUIRED — used by the auth middleware to encrypt session tokens.
2323+# This was MISSING from the migration plan's docker-compose — do not skip this.
2424+# Must be a 64-character hex string (32 bytes of randomness).
2525+# Generate: node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))"
2626+TOKEN_ENCRYPTION_KEY=64-char-hex-string-here
2727+2828+# ── Frontend URL ──────────────────────────────────────
2929+# The public URL of the frontend. Used for:
3030+# - CORS origin validation in the API
3131+# - OAuth redirect URI construction
3232+# Local testing: http://localhost
3333+# Production: https://atlast.byarielm.fyi
3434+FRONTEND_URL=http://localhost
3535+3636+# ── Domain (Traefik Router) ───────────────────────────
3737+# The hostname Traefik uses to route requests to the frontend container.
3838+# Defaults to "localhost" if not set (Compose env var fallback: ${DOMAIN:-localhost}).
3939+# Local testing: localhost
4040+# Production: atlast.byarielm.fyi
4141+DOMAIN=localhost
4242+4343+# ── Cloudflare Tunnel ─────────────────────────────────
4444+# Token from Cloudflare Zero Trust dashboard: Access → Tunnels.
4545+# The tunnel connects your home server to Cloudflare without opening inbound ports.
4646+CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token-here
+80
docker/api/Dockerfile
···11+# ─── Stage 1: Builder ────────────────────────────────────────────────────────
22+# This stage has all the dev tools needed to compile TypeScript.
33+# It will NOT be included in the final image.
44+FROM node:20-alpine AS builder
55+66+WORKDIR /app
77+88+# Enable Corepack and activate the exact pnpm version from package.json.
99+# This ensures we use pnpm 10.28.0 — the same version that generated the lockfile.
1010+# Using a different pnpm version with --frozen-lockfile can cause build failures.
1111+RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
1212+1313+# ── Layer caching: copy package manifests BEFORE source code ──
1414+# Docker caches each layer. If these files do not change, the
1515+# expensive `pnpm install` step below is skipped on subsequent builds.
1616+# Changing a .ts source file will NOT invalidate this layer.
1717+# Copy ALL workspace package.json files before running pnpm install.
1818+# pnpm needs to see every package in the workspace to correctly resolve
1919+# the lockfile and install each package's dependencies into the virtual store.
2020+# Without all 6 package.json files, workspace package deps (kysely, hono, pg)
2121+# are not linked even though the install appears to succeed.
2222+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
2323+COPY packages/api/package.json ./packages/api/
2424+COPY packages/shared/package.json ./packages/shared/
2525+COPY packages/worker/package.json ./packages/worker/
2626+COPY packages/web/package.json ./packages/web/
2727+COPY packages/functions/package.json ./packages/functions/
2828+COPY packages/extension/package.json ./packages/extension/
2929+3030+# Install ALL dependencies (including devDependencies needed for TypeScript compilation).
3131+# --frozen-lockfile: fail if pnpm-lock.yaml would be updated (ensures reproducible builds).
3232+RUN pnpm install --frozen-lockfile
3333+3434+# Now copy source code. This layer changes often, but that is fine because
3535+# the slow `pnpm install` layer above is already cached.
3636+COPY packages/api ./packages/api
3737+COPY packages/shared ./packages/shared
3838+3939+# Build shared package first — api imports from @atlast/shared.
4040+# The TypeScript compiler needs shared's output before compiling api.
4141+RUN pnpm --filter=@atlast/shared build
4242+4343+# Build the API package (tsc outputs to packages/api/dist/).
4444+RUN pnpm --filter=@atlast/api build
4545+4646+# Create a self-contained production deployment bundle.
4747+# `pnpm deploy` resolves workspace symlinks (@atlast/shared) into real files,
4848+# installs only production dependencies, and writes everything to /deploy.
4949+# Without this, the container would have broken symlinks to packages that
5050+# do not exist at the expected path inside the container.
5151+# --legacy flag required in pnpm v10 for workspaces without inject-workspace-packages=true
5252+RUN pnpm --filter=@atlast/api --prod deploy --legacy /deploy
5353+5454+5555+# ─── Stage 2: Production ─────────────────────────────────────────────────────
5656+# Start fresh from a minimal Node.js image.
5757+# NOTHING from the builder stage is included — no TypeScript, no pnpm, no source.
5858+FROM node:20-alpine
5959+6060+WORKDIR /app
6161+6262+# Create a non-root user.
6363+# By default, Node.js containers run as root. If an attacker exploits a
6464+# vulnerability in the app, running as non-root limits what they can do.
6565+# They cannot modify system files, install packages, or affect other containers.
6666+RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 -G nodejs
6767+6868+# Copy only the deployment bundle from the builder stage.
6969+# This includes: dist/ (compiled JS) + node_modules/ (resolved deps, no symlinks).
7070+# --chown sets file ownership to the non-root user we created above.
7171+COPY --from=builder --chown=nodejs:nodejs /deploy .
7272+7373+# Switch to non-root user before the process starts.
7474+USER nodejs
7575+7676+# Document which port the app listens on (does not actually open the port).
7777+EXPOSE 3000
7878+7979+# Start the compiled server.
8080+CMD ["node", "dist/server.js"]
+194
docker/docker-compose.yml
···11+# ATlast Production Docker Compose
22+# Run from repo root: docker compose -f docker/docker-compose.yml up -d
33+# Run from docker/ dir: docker compose up -d
44+55+services:
66+77+ # ── PostgreSQL Database ──────────────────────────────────────────────────────
88+ # Stores all application data: sessions, uploads, matches, source accounts.
99+ # Lives ONLY on the backend network — cannot reach the internet.
1010+ database:
1111+ image: postgres:16-alpine
1212+ restart: unless-stopped
1313+ environment:
1414+ POSTGRES_USER: atlast
1515+ POSTGRES_PASSWORD: ${DB_PASSWORD}
1616+ POSTGRES_DB: atlast
1717+ volumes:
1818+ # Persist database data across container restarts.
1919+ - pgdata:/var/lib/postgresql/data
2020+ # Initialize schema on first run. Postgres runs scripts in
2121+ # /docker-entrypoint-initdb.d/ only when the data volume is empty.
2222+ - ../scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql
2323+ networks:
2424+ - backend
2525+ healthcheck:
2626+ # pg_isready checks if PostgreSQL is accepting connections.
2727+ # Other services use `condition: service_healthy` to wait for this.
2828+ test: ["CMD-SHELL", "pg_isready -U atlast"]
2929+ interval: 10s
3030+ timeout: 5s
3131+ retries: 5
3232+ start_period: 10s
3333+3434+ # ── Redis ────────────────────────────────────────────────────────────────────
3535+ # Used by BullMQ for the job queue (cleanup worker).
3636+ # Also on the backend network — not reachable from the internet.
3737+ redis:
3838+ image: redis:7-alpine
3939+ restart: unless-stopped
4040+ # --appendonly yes: write every operation to disk for durability.
4141+ # Without this, a Redis restart loses all queued jobs.
4242+ command: redis-server --appendonly yes
4343+ volumes:
4444+ - redisdata:/data
4545+ networks:
4646+ - backend
4747+ healthcheck:
4848+ test: ["CMD", "redis-cli", "ping"]
4949+ interval: 10s
5050+ timeout: 3s
5151+ retries: 3
5252+ start_period: 5s
5353+5454+ # ── Hono API Server ──────────────────────────────────────────────────────────
5555+ # The main backend: handles all /api/* requests.
5656+ # On both networks: talks to database/redis (backend), and receives requests
5757+ # from the frontend nginx proxy (frontend).
5858+ api:
5959+ build:
6060+ # Build context is the repo root (one level up from docker/).
6161+ # All COPY paths in docker/api/Dockerfile are relative to here.
6262+ context: ..
6363+ dockerfile: docker/api/Dockerfile
6464+ restart: unless-stopped
6565+ environment:
6666+ - NODE_ENV=production
6767+ # Uses Docker's internal DNS: "database" resolves to the postgres container's IP.
6868+ - DATABASE_URL=postgresql://atlast:${DB_PASSWORD}@database:5432/atlast
6969+ - REDIS_URL=redis://redis:6379
7070+ - OAUTH_PRIVATE_KEY=${OAUTH_PRIVATE_KEY}
7171+ - FRONTEND_URL=${FRONTEND_URL}
7272+ # TOKEN_ENCRYPTION_KEY is required by the auth middleware.
7373+ # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
7474+ # WARNING: The migration plan's docker-compose was missing this variable.
7575+ - TOKEN_ENCRYPTION_KEY=${TOKEN_ENCRYPTION_KEY}
7676+ - PORT=3000
7777+ depends_on:
7878+ database:
7979+ # Wait until PostgreSQL is actually ready, not just started.
8080+ condition: service_healthy
8181+ redis:
8282+ condition: service_healthy
8383+ networks:
8484+ - frontend
8585+ - backend
8686+ healthcheck:
8787+ # wget is available in alpine; curl is not installed by default.
8888+ # -q suppresses output; --spider does a HEAD request only.
8989+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
9090+ interval: 30s
9191+ timeout: 10s
9292+ retries: 3
9393+ start_period: 15s
9494+9595+ # ── BullMQ Worker ────────────────────────────────────────────────────────────
9696+ # Background job processor. Runs the daily cleanup job at 2 AM.
9797+ # Only needs the backend network (database + redis). No inbound connections.
9898+ worker:
9999+ build:
100100+ context: ..
101101+ dockerfile: docker/worker/Dockerfile
102102+ restart: unless-stopped
103103+ environment:
104104+ - NODE_ENV=production
105105+ - DATABASE_URL=postgresql://atlast:${DB_PASSWORD}@database:5432/atlast
106106+ - REDIS_URL=redis://redis:6379
107107+ depends_on:
108108+ database:
109109+ condition: service_healthy
110110+ redis:
111111+ condition: service_healthy
112112+ networks:
113113+ - backend
114114+115115+ # ── Frontend (Nginx) ─────────────────────────────────────────────────────────
116116+ # Serves the compiled React app.
117117+ # Proxies /api/* requests to the api container (same-origin, no CORS needed).
118118+ frontend:
119119+ build:
120120+ context: ..
121121+ dockerfile: docker/frontend/Dockerfile
122122+ restart: unless-stopped
123123+ depends_on:
124124+ api:
125125+ condition: service_healthy
126126+ networks:
127127+ - frontend
128128+ labels:
129129+ # Tell Traefik to route traffic to this container.
130130+ # Without these labels, Traefik ignores this service (exposedbydefault=false).
131131+ # DOMAIN defaults to "localhost" for local testing; set to production hostname in .env.
132132+ - "traefik.enable=true"
133133+ - "traefik.http.routers.frontend.rule=Host(`${DOMAIN:-localhost}`)"
134134+ - "traefik.http.routers.frontend.entrypoints=web"
135135+ - "traefik.http.services.frontend.loadbalancer.server.port=80"
136136+137137+ # ── Traefik Reverse Proxy ────────────────────────────────────────────────────
138138+ # Sits in front of the frontend and handles routing.
139139+ # Only binds to 127.0.0.1 — Cloudflare Tunnel connects to it locally.
140140+ # The internet never connects directly to this port.
141141+ traefik:
142142+ image: traefik:v3.0
143143+ restart: unless-stopped
144144+ command:
145145+ - "--providers.docker=true"
146146+ - "--providers.docker.exposedbydefault=false"
147147+ - "--entrypoints.web.address=:80"
148148+ - "--accesslog=true"
149149+ ports:
150150+ # Bind only to localhost. Cloudflare Tunnel reaches this port.
151151+ # Traffic path: Internet → Cloudflare → Tunnel → 127.0.0.1:80 → Traefik → frontend
152152+ - "127.0.0.1:80:80"
153153+ volumes:
154154+ # Read-only access to Docker socket so Traefik can discover containers.
155155+ - /var/run/docker.sock:/var/run/docker.sock:ro
156156+ networks:
157157+ - frontend
158158+ labels:
159159+ # Do not expose Traefik itself through Traefik.
160160+ - "traefik.enable=false"
161161+162162+ # ── Cloudflare Tunnel ────────────────────────────────────────────────────────
163163+ # Creates an outbound tunnel from this machine to Cloudflare's network.
164164+ # Your home server never needs an open inbound port.
165165+ # Tunnel token is obtained from the Cloudflare Zero Trust dashboard.
166166+ cloudflared:
167167+ image: cloudflare/cloudflared:latest
168168+ restart: unless-stopped
169169+ command: tunnel run
170170+ environment:
171171+ - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
172172+ networks:
173173+ - frontend
174174+175175+# ── Networks ──────────────────────────────────────────────────────────────────
176176+networks:
177177+ # frontend network: traefik, cloudflared, api, and frontend can communicate.
178178+ # Has normal internet access (needed by cloudflared to reach Cloudflare).
179179+ frontend:
180180+ driver: bridge
181181+182182+ # backend network: database, redis, api, and worker can communicate.
183183+ # internal: true means NO outbound internet access from any container on this network.
184184+ # Even if the database or redis container is compromised, it cannot phone home.
185185+ backend:
186186+ driver: bridge
187187+ internal: true
188188+189189+# ── Volumes ───────────────────────────────────────────────────────────────────
190190+volumes:
191191+ # Docker-managed volumes persist data across container restarts and rebuilds.
192192+ # Data lives at /var/lib/docker/volumes/ on the host machine.
193193+ pgdata:
194194+ redisdata:
+51
docker/frontend/Dockerfile
···11+# ─── Stage 1: Builder ────────────────────────────────────────────────────────
22+FROM node:20-alpine AS builder
33+44+WORKDIR /app
55+66+RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
77+88+# Copy ALL workspace package.json files before running pnpm install.
99+# pnpm needs to see every package in the workspace to correctly resolve
1010+# the lockfile and install each package's dependencies into the virtual store.
1111+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
1212+COPY packages/web/package.json ./packages/web/
1313+COPY packages/shared/package.json ./packages/shared/
1414+COPY packages/api/package.json ./packages/api/
1515+COPY packages/worker/package.json ./packages/worker/
1616+COPY packages/functions/package.json ./packages/functions/
1717+COPY packages/extension/package.json ./packages/extension/
1818+1919+RUN pnpm install --frozen-lockfile
2020+2121+COPY packages/web ./packages/web
2222+COPY packages/shared ./packages/shared
2323+2424+# Build shared first in case web imports any shared types at build time.
2525+RUN pnpm --filter=@atlast/shared build
2626+2727+# Build the React app with Vite.
2828+# The API base URL is handled by the nginx proxy, so no VITE_API_BASE override
2929+# needs to be baked into the image. All /api/ requests will be proxied by nginx
3030+# to the api container. This makes the same image work in any environment.
3131+WORKDIR /app/packages/web
3232+RUN pnpm build
3333+3434+3535+# ─── Stage 2: Production (Nginx) ─────────────────────────────────────────────
3636+# Switch to the official Nginx image — a static file server with config.
3737+# This stage is tiny: the entire Node.js builder stage is discarded.
3838+FROM nginx:alpine
3939+4040+# Copy the compiled React app into Nginx's web root.
4141+COPY --from=builder /app/packages/web/dist /usr/share/nginx/html
4242+4343+# Replace Nginx's default config with ours.
4444+# Our config adds: SPA routing, /api/ proxy, security headers, gzip, asset caching.
4545+# NOTE: this path is relative to the Docker build context (repo root),
4646+# because docker-compose.yml sets context: ..
4747+COPY docker/frontend/nginx.conf /etc/nginx/nginx.conf
4848+4949+EXPOSE 80
5050+5151+CMD ["nginx", "-g", "daemon off;"]
+57
docker/frontend/nginx.conf
···11+events {
22+ worker_connections 1024;
33+}
44+55+http {
66+ include /etc/nginx/mime.types;
77+ default_type application/octet-stream;
88+99+ # Security headers applied to all responses.
1010+ # These protect against common web attacks:
1111+ # - X-Frame-Options: prevents clickjacking (your page in an iframe)
1212+ # - X-Content-Type-Options: prevents MIME sniffing attacks
1313+ # - Referrer-Policy: limits URL info sent to third parties
1414+ add_header X-Frame-Options "DENY" always;
1515+ add_header X-Content-Type-Options "nosniff" always;
1616+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
1717+1818+ server {
1919+ listen 80;
2020+ server_name _;
2121+ root /usr/share/nginx/html;
2222+ index index.html;
2323+2424+ # Gzip compression: reduces transfer size for text assets.
2525+ gzip on;
2626+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
2727+2828+ # API proxy: forward /api/* requests to the Hono API container.
2929+ #
3030+ # Without this proxy, the browser would send /api/search to the frontend
3131+ # domain, which does not handle API requests. We would need CORS headers.
3232+ # With this proxy, the browser sees ONE origin for everything.
3333+ # The api container is reachable via Docker's internal DNS as "api:3000".
3434+ location /api/ {
3535+ proxy_pass http://api:3000;
3636+ proxy_set_header Host $host;
3737+ proxy_set_header X-Real-IP $remote_addr;
3838+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
3939+ proxy_set_header X-Forwarded-Proto $scheme;
4040+ }
4141+4242+ # SPA routing: for any path that does not match a real file,
4343+ # serve index.html and let React Router handle the navigation.
4444+ location / {
4545+ try_files $uri $uri/ /index.html;
4646+ }
4747+4848+ # Cache static assets aggressively.
4949+ # Vite adds content hashes to filenames (e.g., main.a1b2c3.js),
5050+ # so these URLs never change for the same content.
5151+ # Browsers can cache them for a full year safely.
5252+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
5353+ expires 1y;
5454+ add_header Cache-Control "public, immutable";
5555+ }
5656+ }
5757+}
+50
docker/worker/Dockerfile
···11+# ─── Stage 1: Builder ────────────────────────────────────────────────────────
22+FROM node:20-alpine AS builder
33+44+WORKDIR /app
55+66+RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
77+88+# Copy ALL workspace package.json files before running pnpm install.
99+# pnpm needs to see every package in the workspace to correctly resolve
1010+# the lockfile and install each package's dependencies into the virtual store.
1111+# Without all 6 package.json files, workspace package deps (kysely, bullmq, pg)
1212+# are not linked even though the install appears to succeed.
1313+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
1414+COPY packages/worker/package.json ./packages/worker/
1515+COPY packages/shared/package.json ./packages/shared/
1616+COPY packages/api/package.json ./packages/api/
1717+COPY packages/web/package.json ./packages/web/
1818+COPY packages/functions/package.json ./packages/functions/
1919+COPY packages/extension/package.json ./packages/extension/
2020+2121+RUN pnpm install --frozen-lockfile
2222+2323+COPY packages/worker ./packages/worker
2424+COPY packages/shared ./packages/shared
2525+2626+# Build shared first (worker imports Database type from @atlast/shared).
2727+RUN pnpm --filter=@atlast/shared build
2828+2929+# Build the worker package (tsc outputs to packages/worker/dist/).
3030+RUN pnpm --filter=@atlast/worker build
3131+3232+# Create production deployment bundle with resolved workspace dependencies.
3333+# --legacy flag required in pnpm v10 for workspaces without inject-workspace-packages=true
3434+RUN pnpm --filter=@atlast/worker --prod deploy --legacy /deploy
3535+3636+3737+# ─── Stage 2: Production ─────────────────────────────────────────────────────
3838+FROM node:20-alpine
3939+4040+WORKDIR /app
4141+4242+RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 -G nodejs
4343+4444+# Copy deployment bundle: dist/ (compiled JS) + node_modules/ (production deps).
4545+COPY --from=builder --chown=nodejs:nodejs /deploy .
4646+4747+USER nodejs
4848+4949+# Worker does not expose any port — it only connects outward to Redis and PostgreSQL.
5050+CMD ["node", "dist/index.js"]