ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

feat(docker): add production Docker configuration for all services

byarielm.fyi 6b52bee1 db664adf

verified
+503
+25
.dockerignore
··· 1 + # Node modules — never copy local node_modules into Docker builds. 2 + # pnpm install inside the container creates its own virtual store and symlinks. 3 + # If local node_modules are copied in, they overwrite container symlinks with 4 + # paths pointing to the local machine's pnpm store (which doesn't exist in container). 5 + node_modules 6 + **/node_modules 7 + 8 + # Compiled output — Docker builds TypeScript from source 9 + **/dist 10 + 11 + # Git 12 + .git 13 + .gitignore 14 + 15 + # Secrets — never include in build context 16 + docker/.env 17 + docker/.env.* 18 + .env 19 + .env.* 20 + *.local 21 + 22 + # IDE / OS 23 + .vscode 24 + .claude 25 + *.log
+46
docker/.env.example
··· 1 + # ═══════════════════════════════════════════════════════ 2 + # ATlast Docker Environment Variables — TEMPLATE 3 + # ═══════════════════════════════════════════════════════ 4 + # Copy this file to docker/.env and fill in real values. 5 + # NEVER commit docker/.env to git. 6 + # 7 + # Generate secrets with: bash scripts/generate-secrets.sh 8 + # ═══════════════════════════════════════════════════════ 9 + 10 + # ── Database ────────────────────────────────────────── 11 + # Strong random password for the PostgreSQL 'atlast' user. 12 + # Generate: node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))" 13 + DB_PASSWORD=change-me-use-a-strong-password 14 + 15 + # ── OAuth (Bluesky Login) ───────────────────────────── 16 + # EC private key in PEM format, used to sign OAuth tokens. 17 + # Generate: openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt 18 + # The key spans multiple lines — use \n to represent newlines in a .env file. 19 + OAUTH_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...your key here...\n-----END PRIVATE KEY----- 20 + 21 + # ── Token Encryption ────────────────────────────────── 22 + # REQUIRED — used by the auth middleware to encrypt session tokens. 23 + # This was MISSING from the migration plan's docker-compose — do not skip this. 24 + # Must be a 64-character hex string (32 bytes of randomness). 25 + # Generate: node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))" 26 + TOKEN_ENCRYPTION_KEY=64-char-hex-string-here 27 + 28 + # ── Frontend URL ────────────────────────────────────── 29 + # The public URL of the frontend. Used for: 30 + # - CORS origin validation in the API 31 + # - OAuth redirect URI construction 32 + # Local testing: http://localhost 33 + # Production: https://atlast.byarielm.fyi 34 + FRONTEND_URL=http://localhost 35 + 36 + # ── Domain (Traefik Router) ─────────────────────────── 37 + # The hostname Traefik uses to route requests to the frontend container. 38 + # Defaults to "localhost" if not set (Compose env var fallback: ${DOMAIN:-localhost}). 39 + # Local testing: localhost 40 + # Production: atlast.byarielm.fyi 41 + DOMAIN=localhost 42 + 43 + # ── Cloudflare Tunnel ───────────────────────────────── 44 + # Token from Cloudflare Zero Trust dashboard: Access → Tunnels. 45 + # The tunnel connects your home server to Cloudflare without opening inbound ports. 46 + CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token-here
+80
docker/api/Dockerfile
··· 1 + # ─── Stage 1: Builder ──────────────────────────────────────────────────────── 2 + # This stage has all the dev tools needed to compile TypeScript. 3 + # It will NOT be included in the final image. 4 + FROM node:20-alpine AS builder 5 + 6 + WORKDIR /app 7 + 8 + # Enable Corepack and activate the exact pnpm version from package.json. 9 + # This ensures we use pnpm 10.28.0 — the same version that generated the lockfile. 10 + # Using a different pnpm version with --frozen-lockfile can cause build failures. 11 + RUN corepack enable && corepack prepare pnpm@10.28.0 --activate 12 + 13 + # ── Layer caching: copy package manifests BEFORE source code ── 14 + # Docker caches each layer. If these files do not change, the 15 + # expensive `pnpm install` step below is skipped on subsequent builds. 16 + # Changing a .ts source file will NOT invalidate this layer. 17 + # Copy ALL workspace package.json files before running pnpm install. 18 + # pnpm needs to see every package in the workspace to correctly resolve 19 + # the lockfile and install each package's dependencies into the virtual store. 20 + # Without all 6 package.json files, workspace package deps (kysely, hono, pg) 21 + # are not linked even though the install appears to succeed. 22 + COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ 23 + COPY packages/api/package.json ./packages/api/ 24 + COPY packages/shared/package.json ./packages/shared/ 25 + COPY packages/worker/package.json ./packages/worker/ 26 + COPY packages/web/package.json ./packages/web/ 27 + COPY packages/functions/package.json ./packages/functions/ 28 + COPY packages/extension/package.json ./packages/extension/ 29 + 30 + # Install ALL dependencies (including devDependencies needed for TypeScript compilation). 31 + # --frozen-lockfile: fail if pnpm-lock.yaml would be updated (ensures reproducible builds). 32 + RUN pnpm install --frozen-lockfile 33 + 34 + # Now copy source code. This layer changes often, but that is fine because 35 + # the slow `pnpm install` layer above is already cached. 36 + COPY packages/api ./packages/api 37 + COPY packages/shared ./packages/shared 38 + 39 + # Build shared package first — api imports from @atlast/shared. 40 + # The TypeScript compiler needs shared's output before compiling api. 41 + RUN pnpm --filter=@atlast/shared build 42 + 43 + # Build the API package (tsc outputs to packages/api/dist/). 44 + RUN pnpm --filter=@atlast/api build 45 + 46 + # Create a self-contained production deployment bundle. 47 + # `pnpm deploy` resolves workspace symlinks (@atlast/shared) into real files, 48 + # installs only production dependencies, and writes everything to /deploy. 49 + # Without this, the container would have broken symlinks to packages that 50 + # do not exist at the expected path inside the container. 51 + # --legacy flag required in pnpm v10 for workspaces without inject-workspace-packages=true 52 + RUN pnpm --filter=@atlast/api --prod deploy --legacy /deploy 53 + 54 + 55 + # ─── Stage 2: Production ───────────────────────────────────────────────────── 56 + # Start fresh from a minimal Node.js image. 57 + # NOTHING from the builder stage is included — no TypeScript, no pnpm, no source. 58 + FROM node:20-alpine 59 + 60 + WORKDIR /app 61 + 62 + # Create a non-root user. 63 + # By default, Node.js containers run as root. If an attacker exploits a 64 + # vulnerability in the app, running as non-root limits what they can do. 65 + # They cannot modify system files, install packages, or affect other containers. 66 + RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 -G nodejs 67 + 68 + # Copy only the deployment bundle from the builder stage. 69 + # This includes: dist/ (compiled JS) + node_modules/ (resolved deps, no symlinks). 70 + # --chown sets file ownership to the non-root user we created above. 71 + COPY --from=builder --chown=nodejs:nodejs /deploy . 72 + 73 + # Switch to non-root user before the process starts. 74 + USER nodejs 75 + 76 + # Document which port the app listens on (does not actually open the port). 77 + EXPOSE 3000 78 + 79 + # Start the compiled server. 80 + CMD ["node", "dist/server.js"]
+194
docker/docker-compose.yml
··· 1 + # ATlast Production Docker Compose 2 + # Run from repo root: docker compose -f docker/docker-compose.yml up -d 3 + # Run from docker/ dir: docker compose up -d 4 + 5 + services: 6 + 7 + # ── PostgreSQL Database ────────────────────────────────────────────────────── 8 + # Stores all application data: sessions, uploads, matches, source accounts. 9 + # Lives ONLY on the backend network — cannot reach the internet. 10 + database: 11 + image: postgres:16-alpine 12 + restart: unless-stopped 13 + environment: 14 + POSTGRES_USER: atlast 15 + POSTGRES_PASSWORD: ${DB_PASSWORD} 16 + POSTGRES_DB: atlast 17 + volumes: 18 + # Persist database data across container restarts. 19 + - pgdata:/var/lib/postgresql/data 20 + # Initialize schema on first run. Postgres runs scripts in 21 + # /docker-entrypoint-initdb.d/ only when the data volume is empty. 22 + - ../scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql 23 + networks: 24 + - backend 25 + healthcheck: 26 + # pg_isready checks if PostgreSQL is accepting connections. 27 + # Other services use `condition: service_healthy` to wait for this. 28 + test: ["CMD-SHELL", "pg_isready -U atlast"] 29 + interval: 10s 30 + timeout: 5s 31 + retries: 5 32 + start_period: 10s 33 + 34 + # ── Redis ──────────────────────────────────────────────────────────────────── 35 + # Used by BullMQ for the job queue (cleanup worker). 36 + # Also on the backend network — not reachable from the internet. 37 + redis: 38 + image: redis:7-alpine 39 + restart: unless-stopped 40 + # --appendonly yes: write every operation to disk for durability. 41 + # Without this, a Redis restart loses all queued jobs. 42 + command: redis-server --appendonly yes 43 + volumes: 44 + - redisdata:/data 45 + networks: 46 + - backend 47 + healthcheck: 48 + test: ["CMD", "redis-cli", "ping"] 49 + interval: 10s 50 + timeout: 3s 51 + retries: 3 52 + start_period: 5s 53 + 54 + # ── Hono API Server ────────────────────────────────────────────────────────── 55 + # The main backend: handles all /api/* requests. 56 + # On both networks: talks to database/redis (backend), and receives requests 57 + # from the frontend nginx proxy (frontend). 58 + api: 59 + build: 60 + # Build context is the repo root (one level up from docker/). 61 + # All COPY paths in docker/api/Dockerfile are relative to here. 62 + context: .. 63 + dockerfile: docker/api/Dockerfile 64 + restart: unless-stopped 65 + environment: 66 + - NODE_ENV=production 67 + # Uses Docker's internal DNS: "database" resolves to the postgres container's IP. 68 + - DATABASE_URL=postgresql://atlast:${DB_PASSWORD}@database:5432/atlast 69 + - REDIS_URL=redis://redis:6379 70 + - OAUTH_PRIVATE_KEY=${OAUTH_PRIVATE_KEY} 71 + - FRONTEND_URL=${FRONTEND_URL} 72 + # TOKEN_ENCRYPTION_KEY is required by the auth middleware. 73 + # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" 74 + # WARNING: The migration plan's docker-compose was missing this variable. 75 + - TOKEN_ENCRYPTION_KEY=${TOKEN_ENCRYPTION_KEY} 76 + - PORT=3000 77 + depends_on: 78 + database: 79 + # Wait until PostgreSQL is actually ready, not just started. 80 + condition: service_healthy 81 + redis: 82 + condition: service_healthy 83 + networks: 84 + - frontend 85 + - backend 86 + healthcheck: 87 + # wget is available in alpine; curl is not installed by default. 88 + # -q suppresses output; --spider does a HEAD request only. 89 + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] 90 + interval: 30s 91 + timeout: 10s 92 + retries: 3 93 + start_period: 15s 94 + 95 + # ── BullMQ Worker ──────────────────────────────────────────────────────────── 96 + # Background job processor. Runs the daily cleanup job at 2 AM. 97 + # Only needs the backend network (database + redis). No inbound connections. 98 + worker: 99 + build: 100 + context: .. 101 + dockerfile: docker/worker/Dockerfile 102 + restart: unless-stopped 103 + environment: 104 + - NODE_ENV=production 105 + - DATABASE_URL=postgresql://atlast:${DB_PASSWORD}@database:5432/atlast 106 + - REDIS_URL=redis://redis:6379 107 + depends_on: 108 + database: 109 + condition: service_healthy 110 + redis: 111 + condition: service_healthy 112 + networks: 113 + - backend 114 + 115 + # ── Frontend (Nginx) ───────────────────────────────────────────────────────── 116 + # Serves the compiled React app. 117 + # Proxies /api/* requests to the api container (same-origin, no CORS needed). 118 + frontend: 119 + build: 120 + context: .. 121 + dockerfile: docker/frontend/Dockerfile 122 + restart: unless-stopped 123 + depends_on: 124 + api: 125 + condition: service_healthy 126 + networks: 127 + - frontend 128 + labels: 129 + # Tell Traefik to route traffic to this container. 130 + # Without these labels, Traefik ignores this service (exposedbydefault=false). 131 + # DOMAIN defaults to "localhost" for local testing; set to production hostname in .env. 132 + - "traefik.enable=true" 133 + - "traefik.http.routers.frontend.rule=Host(`${DOMAIN:-localhost}`)" 134 + - "traefik.http.routers.frontend.entrypoints=web" 135 + - "traefik.http.services.frontend.loadbalancer.server.port=80" 136 + 137 + # ── Traefik Reverse Proxy ──────────────────────────────────────────────────── 138 + # Sits in front of the frontend and handles routing. 139 + # Only binds to 127.0.0.1 — Cloudflare Tunnel connects to it locally. 140 + # The internet never connects directly to this port. 141 + traefik: 142 + image: traefik:v3.0 143 + restart: unless-stopped 144 + command: 145 + - "--providers.docker=true" 146 + - "--providers.docker.exposedbydefault=false" 147 + - "--entrypoints.web.address=:80" 148 + - "--accesslog=true" 149 + ports: 150 + # Bind only to localhost. Cloudflare Tunnel reaches this port. 151 + # Traffic path: Internet → Cloudflare → Tunnel → 127.0.0.1:80 → Traefik → frontend 152 + - "127.0.0.1:80:80" 153 + volumes: 154 + # Read-only access to Docker socket so Traefik can discover containers. 155 + - /var/run/docker.sock:/var/run/docker.sock:ro 156 + networks: 157 + - frontend 158 + labels: 159 + # Do not expose Traefik itself through Traefik. 160 + - "traefik.enable=false" 161 + 162 + # ── Cloudflare Tunnel ──────────────────────────────────────────────────────── 163 + # Creates an outbound tunnel from this machine to Cloudflare's network. 164 + # Your home server never needs an open inbound port. 165 + # Tunnel token is obtained from the Cloudflare Zero Trust dashboard. 166 + cloudflared: 167 + image: cloudflare/cloudflared:latest 168 + restart: unless-stopped 169 + command: tunnel run 170 + environment: 171 + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} 172 + networks: 173 + - frontend 174 + 175 + # ── Networks ────────────────────────────────────────────────────────────────── 176 + networks: 177 + # frontend network: traefik, cloudflared, api, and frontend can communicate. 178 + # Has normal internet access (needed by cloudflared to reach Cloudflare). 179 + frontend: 180 + driver: bridge 181 + 182 + # backend network: database, redis, api, and worker can communicate. 183 + # internal: true means NO outbound internet access from any container on this network. 184 + # Even if the database or redis container is compromised, it cannot phone home. 185 + backend: 186 + driver: bridge 187 + internal: true 188 + 189 + # ── Volumes ─────────────────────────────────────────────────────────────────── 190 + volumes: 191 + # Docker-managed volumes persist data across container restarts and rebuilds. 192 + # Data lives at /var/lib/docker/volumes/ on the host machine. 193 + pgdata: 194 + redisdata:
+51
docker/frontend/Dockerfile
··· 1 + # ─── Stage 1: Builder ──────────────────────────────────────────────────────── 2 + FROM node:20-alpine AS builder 3 + 4 + WORKDIR /app 5 + 6 + RUN corepack enable && corepack prepare pnpm@10.28.0 --activate 7 + 8 + # Copy ALL workspace package.json files before running pnpm install. 9 + # pnpm needs to see every package in the workspace to correctly resolve 10 + # the lockfile and install each package's dependencies into the virtual store. 11 + COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ 12 + COPY packages/web/package.json ./packages/web/ 13 + COPY packages/shared/package.json ./packages/shared/ 14 + COPY packages/api/package.json ./packages/api/ 15 + COPY packages/worker/package.json ./packages/worker/ 16 + COPY packages/functions/package.json ./packages/functions/ 17 + COPY packages/extension/package.json ./packages/extension/ 18 + 19 + RUN pnpm install --frozen-lockfile 20 + 21 + COPY packages/web ./packages/web 22 + COPY packages/shared ./packages/shared 23 + 24 + # Build shared first in case web imports any shared types at build time. 25 + RUN pnpm --filter=@atlast/shared build 26 + 27 + # Build the React app with Vite. 28 + # The API base URL is handled by the nginx proxy, so no VITE_API_BASE override 29 + # needs to be baked into the image. All /api/ requests will be proxied by nginx 30 + # to the api container. This makes the same image work in any environment. 31 + WORKDIR /app/packages/web 32 + RUN pnpm build 33 + 34 + 35 + # ─── Stage 2: Production (Nginx) ───────────────────────────────────────────── 36 + # Switch to the official Nginx image — a static file server with config. 37 + # This stage is tiny: the entire Node.js builder stage is discarded. 38 + FROM nginx:alpine 39 + 40 + # Copy the compiled React app into Nginx's web root. 41 + COPY --from=builder /app/packages/web/dist /usr/share/nginx/html 42 + 43 + # Replace Nginx's default config with ours. 44 + # Our config adds: SPA routing, /api/ proxy, security headers, gzip, asset caching. 45 + # NOTE: this path is relative to the Docker build context (repo root), 46 + # because docker-compose.yml sets context: .. 47 + COPY docker/frontend/nginx.conf /etc/nginx/nginx.conf 48 + 49 + EXPOSE 80 50 + 51 + CMD ["nginx", "-g", "daemon off;"]
+57
docker/frontend/nginx.conf
··· 1 + events { 2 + worker_connections 1024; 3 + } 4 + 5 + http { 6 + include /etc/nginx/mime.types; 7 + default_type application/octet-stream; 8 + 9 + # Security headers applied to all responses. 10 + # These protect against common web attacks: 11 + # - X-Frame-Options: prevents clickjacking (your page in an iframe) 12 + # - X-Content-Type-Options: prevents MIME sniffing attacks 13 + # - Referrer-Policy: limits URL info sent to third parties 14 + add_header X-Frame-Options "DENY" always; 15 + add_header X-Content-Type-Options "nosniff" always; 16 + add_header Referrer-Policy "strict-origin-when-cross-origin" always; 17 + 18 + server { 19 + listen 80; 20 + server_name _; 21 + root /usr/share/nginx/html; 22 + index index.html; 23 + 24 + # Gzip compression: reduces transfer size for text assets. 25 + gzip on; 26 + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; 27 + 28 + # API proxy: forward /api/* requests to the Hono API container. 29 + # 30 + # Without this proxy, the browser would send /api/search to the frontend 31 + # domain, which does not handle API requests. We would need CORS headers. 32 + # With this proxy, the browser sees ONE origin for everything. 33 + # The api container is reachable via Docker's internal DNS as "api:3000". 34 + location /api/ { 35 + proxy_pass http://api:3000; 36 + proxy_set_header Host $host; 37 + proxy_set_header X-Real-IP $remote_addr; 38 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 39 + proxy_set_header X-Forwarded-Proto $scheme; 40 + } 41 + 42 + # SPA routing: for any path that does not match a real file, 43 + # serve index.html and let React Router handle the navigation. 44 + location / { 45 + try_files $uri $uri/ /index.html; 46 + } 47 + 48 + # Cache static assets aggressively. 49 + # Vite adds content hashes to filenames (e.g., main.a1b2c3.js), 50 + # so these URLs never change for the same content. 51 + # Browsers can cache them for a full year safely. 52 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { 53 + expires 1y; 54 + add_header Cache-Control "public, immutable"; 55 + } 56 + } 57 + }
+50
docker/worker/Dockerfile
··· 1 + # ─── Stage 1: Builder ──────────────────────────────────────────────────────── 2 + FROM node:20-alpine AS builder 3 + 4 + WORKDIR /app 5 + 6 + RUN corepack enable && corepack prepare pnpm@10.28.0 --activate 7 + 8 + # Copy ALL workspace package.json files before running pnpm install. 9 + # pnpm needs to see every package in the workspace to correctly resolve 10 + # the lockfile and install each package's dependencies into the virtual store. 11 + # Without all 6 package.json files, workspace package deps (kysely, bullmq, pg) 12 + # are not linked even though the install appears to succeed. 13 + COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ 14 + COPY packages/worker/package.json ./packages/worker/ 15 + COPY packages/shared/package.json ./packages/shared/ 16 + COPY packages/api/package.json ./packages/api/ 17 + COPY packages/web/package.json ./packages/web/ 18 + COPY packages/functions/package.json ./packages/functions/ 19 + COPY packages/extension/package.json ./packages/extension/ 20 + 21 + RUN pnpm install --frozen-lockfile 22 + 23 + COPY packages/worker ./packages/worker 24 + COPY packages/shared ./packages/shared 25 + 26 + # Build shared first (worker imports Database type from @atlast/shared). 27 + RUN pnpm --filter=@atlast/shared build 28 + 29 + # Build the worker package (tsc outputs to packages/worker/dist/). 30 + RUN pnpm --filter=@atlast/worker build 31 + 32 + # Create production deployment bundle with resolved workspace dependencies. 33 + # --legacy flag required in pnpm v10 for workspaces without inject-workspace-packages=true 34 + RUN pnpm --filter=@atlast/worker --prod deploy --legacy /deploy 35 + 36 + 37 + # ─── Stage 2: Production ───────────────────────────────────────────────────── 38 + FROM node:20-alpine 39 + 40 + WORKDIR /app 41 + 42 + RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 -G nodejs 43 + 44 + # Copy deployment bundle: dist/ (compiled JS) + node_modules/ (production deps). 45 + COPY --from=builder --chown=nodejs:nodejs /deploy . 46 + 47 + USER nodejs 48 + 49 + # Worker does not expose any port — it only connects outward to Redis and PostgreSQL. 50 + CMD ["node", "dist/index.js"]