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

Configure Feed

Select the types of activity you want to include in your feed.

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"]