Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions docker/nginx/local-edge.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
27 changes: 27 additions & 0 deletions web/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
74 changes: 74 additions & 0 deletions web/server.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
});
Loading