diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f867fe9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.worktrees +target +contracts/rust/api_contract/target +node_modules +web/node_modules +web/dist +web/test-results +web/playwright-report +.venv +.hypothesis diff --git a/README.md b/README.md index 0af20bb..8ee446e 100644 --- a/README.md +++ b/README.md @@ -121,3 +121,44 @@ Runtime conformance uses `uv` to create a local virtual environment, install Sch ```bash ./scripts/contract-test.sh ``` + +## Production-like local stack with Docker Compose + +The compose setup mirrors a production split-domain topology: + +- `example.com` -> docs site (static Astro build) +- `api.example.com` -> API service +- `edge` (nginx) acts as the local reverse proxy / ingress + +Start the full stack: + +```bash +docker compose up --build +``` + +To route `example.com` and `api.example.com` to your local machine, add these host entries: + +```text +127.0.0.1 example.com +127.0.0.1 api.example.com +``` + +Then visit: + +- `http://example.com` for docs +- `http://api.example.com/now?format=iso` for API + +### Local development allowances + +To keep local debugging convenient, compose also exposes direct ports: + +- `http://localhost:3000` -> docs container directly +- `http://localhost:8080` -> API container directly + +These direct ports are a local-only convenience and are not intended as production exposure. + +### Distroless docs image + +`web/Dockerfile` builds the Astro static site and serves it from a distroless Node runtime. +This keeps the docs runtime image minimal while still allowing static hosting behavior similar +to production. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1264863 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +name: epochapi-local + +services: + api: + build: + context: . + dockerfile: Dockerfile + environment: + ED25519_PRIVATE_KEY_HEX: "1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100" + expose: + - "8080" + ports: + - "8080:8080" + + docs: + build: + context: . + dockerfile: web/Dockerfile + args: + PUBLIC_API_BASE_URL: "http://api.example.com" + expose: + - "3000" + ports: + - "3000:3000" + + edge: + image: nginx:1.27-alpine + depends_on: + - api + - docs + ports: + - "80:80" + volumes: + - ./docker/nginx/local-edge.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/docker/nginx/local-edge.conf b/docker/nginx/local-edge.conf new file mode 100644 index 0000000..3085791 --- /dev/null +++ b/docker/nginx/local-edge.conf @@ -0,0 +1,65 @@ +map $http_origin $cors_origin { + default ""; + "http://example.com" "http://example.com"; + "https://example.com" "https://example.com"; + "http://localhost" "http://localhost"; + "http://localhost:3000" "http://localhost:3000"; +} + +server { + listen 80; + server_name example.com; + + location / { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://docs:3000; + } +} + +server { + listen 80; + server_name api.example.com; + + add_header Access-Control-Allow-Origin $cors_origin always; + add_header Access-Control-Allow-Methods "GET,POST,OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type,Authorization" always; + add_header Access-Control-Max-Age 86400 always; + add_header Vary Origin always; + + if ($request_method = OPTIONS) { + return 204; + } + + location / { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://api:8080; + } +} + +server { + listen 80; + server_name localhost; + + location /api/ { + proxy_http_version 1.1; + proxy_set_header Host api.example.com; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://api:8080; + } + + location / { + proxy_http_version 1.1; + proxy_set_header Host example.com; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://docs:3000; + } +} diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..07db0e6 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS builder +WORKDIR /app + +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +COPY web/ ./ + +ARG PUBLIC_API_BASE_URL=http://api.example.com +ENV PUBLIC_API_BASE_URL=${PUBLIC_API_BASE_URL} + +RUN npm run build + +FROM gcr.io/distroless/nodejs22-debian12:nonroot +WORKDIR /app + +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/server.mjs ./server.mjs + +ENV HOST=0.0.0.0 +ENV PORT=3000 + +EXPOSE 3000 + +CMD ["server.mjs"] diff --git a/web/server.mjs b/web/server.mjs new file mode 100644 index 0000000..a1c16b6 --- /dev/null +++ b/web/server.mjs @@ -0,0 +1,74 @@ +import { createReadStream, existsSync, statSync } from "node:fs"; +import { extname, join, normalize } from "node:path"; +import { createServer } from "node:http"; + +const host = process.env.HOST ?? "0.0.0.0"; +const port = Number(process.env.PORT ?? "3000"); +const root = join(process.cwd(), "dist"); + +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +function safePath(requestPath) { + const normalized = normalize(requestPath).replace(/^\/+/, ""); + return normalized.startsWith("..") ? "" : normalized; +} + +function resolveFile(pathname) { + const relative = safePath(pathname); + if (!relative) return join(root, "index.html"); + + const exact = join(root, relative); + if (existsSync(exact) && statSync(exact).isFile()) { + return exact; + } + + const nestedIndex = join(root, relative, "index.html"); + if (existsSync(nestedIndex)) { + return nestedIndex; + } + + const htmlVariant = join(root, `${relative}.html`); + if (existsSync(htmlVariant)) { + return htmlVariant; + } + + return join(root, "index.html"); +} + +const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + + if (url.pathname === "/health") { + res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); + res.end("ok"); + return; + } + + const filePath = resolveFile(url.pathname); + const extension = extname(filePath); + const contentType = contentTypes[extension] ?? "application/octet-stream"; + + res.writeHead(200, { + "cache-control": extension === ".html" ? "no-cache" : "public, max-age=31536000, immutable", + "content-type": contentType, + "x-content-type-options": "nosniff", + }); + + createReadStream(filePath).pipe(res); +}); + +server.listen(port, host, () => { + console.log(`docs server listening on http://${host}:${port}`); +});