···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
···0000000000000000000000000000000000000000000000
···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
···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"]
···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;"]
···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+}