Add split web Docker Compose PoC (#445)

## Summary

Adds a standalone Docker Compose proof that runs the Fabro Rust API, a
Caddy static SPA server, and a Caddy edge proxy as separate services.
This demonstrates split web asset serving while keeping `/api/*`,
`/auth/*`, and `/health` same-origin with the API server.

## Changes

- Adds `docker-compose.split-web.yaml` with private `fabro-api` and
`fabro-web` services behind an exposed `edge` proxy on port 8080.
- Adds Caddy edge routing that sends `/api/*`, `/auth/*`, and `/health`
to Rust, while everything else goes to the static web service.
- Adds a static Caddy config for `apps/fabro-web/dist` with SPA
fallback, source-map blocking, security headers, immutable asset
caching, and `X-Fabro-PoC-Upstream` route-proof headers.
- Adds PoC server settings and a README with build, run, and validation
commands.

## Verification

- `cargo dev docker-build --tag fabro-sh/fabro:split-web-poc`
- `docker compose -f docker-compose.split-web.yaml up -d`
- `docker compose -f docker-compose.split-web.yaml ps`
- `curl` checks for `/runs`, `/assets/app.css`, `/assets/app.css.map`,
`/api/v1/health`, `/api/v1/auth/config`, `/auth/login/dev-token`,
`/api/v1/auth/me`, and `/api/v1/attach`
- Browser login flow via `browser-use`: loaded `/login`, submitted the
dev token, and landed on the authenticated Runs screen
- `docker compose -f docker-compose.split-web.yaml config`
- `caddy validate` for both Caddyfiles
- `git diff --check`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
This commit is contained in:
Bryan Helmkamp 2026-05-28 00:03:00 -04:00 committed by GitHub
parent 9ee576690b
commit 31a990c4dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 180 additions and 0 deletions

View file

@ -0,0 +1,52 @@
services:
fabro-api:
image: ${FABRO_IMAGE:-fabro-sh/fabro:split-web-poc}
restart: unless-stopped
expose:
- "32276"
command:
- sh
- -c
- >-
exec fabro server start --foreground
--bind 0.0.0.0:32276
--config /config/settings.toml
volumes:
- fabro-storage:/storage
- /var/run/docker.sock:/var/run/docker.sock
- ./docker/split-web/settings.toml:/config/settings.toml:ro
environment:
FABRO_WEB_URL: http://localhost:${SPLIT_WEB_PORT:-8080}
SESSION_SECRET: ${SESSION_SECRET:-}
FABRO_DEV_TOKEN: ${FABRO_DEV_TOKEN:-}
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:32276/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
fabro-web:
image: caddy:2-alpine
restart: unless-stopped
expose:
- "80"
volumes:
- ./apps/fabro-web:/srv/web:ro
- ./docker/split-web/Caddyfile.static:/etc/caddy/Caddyfile:ro
edge:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "${SPLIT_WEB_PORT:-8080}:80"
volumes:
- ./docker/split-web/Caddyfile.edge:/etc/caddy/Caddyfile:ro
depends_on:
fabro-api:
condition: service_healthy
fabro-web:
condition: service_started
volumes:
fabro-storage:

View file

@ -0,0 +1,14 @@
:80 {
encode gzip zstd
@api path /api/* /auth/* /health
handle @api {
header X-Fabro-PoC-Upstream fabro-api
reverse_proxy fabro-api:32276
}
handle {
header X-Fabro-PoC-Upstream fabro-web
reverse_proxy fabro-web:80
}
}

View file

@ -0,0 +1,39 @@
:80 {
root * /srv/web/dist
encode gzip zstd
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Resource-Policy same-origin
Permissions-Policy "accelerometer=(), autoplay=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), usb=(), web-share=(), xr-spatial-tracking=()"
Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://avatars.githubusercontent.com; connect-src 'self' ws: wss:; worker-src 'self' blob:; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
}
@sourceMaps path *.map
handle @sourceMaps {
respond 404
}
@cacheAssets {
path /assets/*
not path *.map
}
header @cacheAssets Cache-Control "public, max-age=31536000, immutable"
@htmlNavigation {
header Accept *text/html*
not path /assets/*
}
handle @htmlNavigation {
try_files {path} /index.html
header Cache-Control "no-cache"
file_server
}
handle {
file_server
}
}

View file

@ -0,0 +1,67 @@
# Split Web Compose PoC
This Compose stack proves that Fabro can serve the React SPA from a separate
static process while the Rust server remains the API and browser-auth origin.
Request ownership:
- `/api/*` -> `fabro-api:32276`
- `/auth/*` -> `fabro-api:32276`
- `/health` -> `fabro-api:32276`
- everything else -> `fabro-web:80`
The Rust server still contains bundled SPA assets. In this PoC they are simply
not reachable through the `edge` service for normal web paths.
The edge proxy adds `X-Fabro-PoC-Upstream` to responses so manual checks can
confirm which service handled a request.
## Run
Build the local Fabro image from the current tree:
```sh
cargo dev docker-build --tag fabro-sh/fabro:split-web-poc
```
Set local auth secrets:
```sh
export SESSION_SECRET="$(openssl rand -hex 32)"
export FABRO_DEV_TOKEN="fabro_dev_$(openssl rand -hex 32)"
```
Start the split stack:
```sh
docker compose -f docker-compose.split-web.yaml up
```
Open http://localhost:8080.
Use `SPLIT_WEB_PORT` to expose a different local port, or `FABRO_IMAGE` to use
a different API image.
## Validate
```sh
curl -i http://localhost:8080/health
curl -i http://localhost:8080/api/v1/health
curl -I http://localhost:8080/runs
curl -I http://localhost:8080/assets/app.css
curl -c /tmp/fabro.cookies \
-H "content-type: application/json" \
-d "{\"token\":\"$FABRO_DEV_TOKEN\"}" \
http://localhost:8080/auth/login/dev-token
curl -b /tmp/fabro.cookies http://localhost:8080/api/v1/auth/me
```
Expected results:
- `/runs` and `/assets/*` are served by the static `fabro-web` container.
- `/api/*`, `/auth/*`, and `/health` are served by the Rust `fabro-api`
container through the same browser origin.
- Dev-token login sets a same-origin session cookie, and
`/api/v1/auth/me` accepts it.

View file

@ -0,0 +1,8 @@
_version = 1
[server.web]
enabled = true
url = "{{ env.FABRO_WEB_URL }}"
[server.auth]
methods = ["dev-token"]