p5-07: reference deployment (server-only compose + reverse-proxy docs)
CI / Test (store) (pull_request) Successful in 21s
CI / Test (rest) (pull_request) Successful in 38s
CI / Lint (pull_request) Successful in 33s
CI / Build (windows/amd64) (pull_request) Successful in 39s
CI / Test (server-http) (pull_request) Successful in 1m17s
CI / Build (linux/amd64) (pull_request) Successful in 23s
CI / Build (linux/arm64) (pull_request) Successful in 39s
CI / Test (store) (pull_request) Successful in 21s
CI / Test (rest) (pull_request) Successful in 38s
CI / Lint (pull_request) Successful in 33s
CI / Build (windows/amd64) (pull_request) Successful in 39s
CI / Test (server-http) (pull_request) Successful in 1m17s
CI / Build (linux/amd64) (pull_request) Successful in 23s
CI / Build (linux/arm64) (pull_request) Successful in 39s
The reverse proxy is assumed to live outside this project (Caddy, nginx, Traefik, whatever the operator already runs). The reference compose stands up only the server: image-pinned via RM_VERSION, named volume for operator state, localhost-bound so the proxy reaches it on loopback. docs/reverse-proxy.md covers what the proxy must forward — the X-Forwarded-* headers, Host, and Connection: upgrade for the agent WebSocket and live-log streams — plus the RM_TRUSTED_PROXY CIDR rule that gates header trust. Worked examples for Caddy, nginx (with the websocket upgrade map + 1h proxy_read_timeout for live logs), and Traefik.
This commit is contained in:
@@ -1,21 +1,52 @@
|
||||
# Reference deployment for the restic-manager control plane.
|
||||
# Mirrors spec.md §10.1. Adjust image tag and RM_BASE_URL for your env.
|
||||
# Mirrors spec.md §10.1 and the P5-07 reference deployment.
|
||||
#
|
||||
# The server speaks plain HTTP. Front it with a TLS-terminating
|
||||
# reverse proxy (Caddy/Traefik/nginx). RM_TRUSTED_PROXY must contain
|
||||
# the proxy's IP/CIDR so X-Forwarded-* headers are honoured.
|
||||
# Scope: this compose stands up the server only. TLS termination and
|
||||
# the public hostname belong to a reverse proxy that lives outside
|
||||
# this stack (Caddy, Traefik, nginx, HAProxy, your existing edge —
|
||||
# whatever you already operate). See `docs/reverse-proxy.md` for the
|
||||
# headers + CIDRs that proxy needs to forward.
|
||||
#
|
||||
# Architecture:
|
||||
# * The server speaks plain HTTP on :8080.
|
||||
# * The agent binaries + install scripts ship inside the image under
|
||||
# /opt/restic-manager/dist/, so /agent/binary and /install/*
|
||||
# serve out of the box without first-run staging.
|
||||
# * The named volume holds *only* operator state (sqlite,
|
||||
# secrets.enc, audit log, the AEAD key). Image upgrades replace
|
||||
# the agents/scripts; the volume is untouched.
|
||||
# * Pre-1.0 releases never publish :latest — pin to an exact
|
||||
# vX.Y.Z tag and bump deliberately.
|
||||
#
|
||||
# Before first start:
|
||||
# 1. Pick a version: export RM_VERSION=vX.Y.Z (or substitute below).
|
||||
# 2. Set RM_BASE_URL to the public HTTPS URL the external proxy
|
||||
# serves on.
|
||||
# 3. Set RM_TRUSTED_PROXY to the IP/CIDR the proxy connects from
|
||||
# (the X-Forwarded-* headers are honoured only when the immediate
|
||||
# peer matches one of these).
|
||||
|
||||
services:
|
||||
restic-manager:
|
||||
image: ghcr.io/dcglab/restic-manager:latest
|
||||
image: gitea.dcglab.co.uk/steve/restic-manager:${RM_VERSION:?set RM_VERSION to a vX.Y.Z tag}
|
||||
restart: unless-stopped
|
||||
# Bind to localhost only — the proxy is what the public reaches.
|
||||
# Bind to localhost only — your reverse proxy reaches the server
|
||||
# over loopback (or, if it runs in a separate compose / on
|
||||
# another host, swap this for an internal docker network or a
|
||||
# private LAN bind).
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- rm-data:/data
|
||||
environment:
|
||||
- RM_DATA_DIR=/data
|
||||
- RM_LISTEN=:8080
|
||||
- RM_BASE_URL=https://restic.lab.example
|
||||
- RM_BASE_URL=${RM_BASE_URL:?set RM_BASE_URL to the public https URL}
|
||||
- RM_SECRET_KEY_FILE=/data/secret.key
|
||||
- RM_TRUSTED_PROXY=172.16.0.0/12
|
||||
- RM_TRUSTED_PROXY=${RM_TRUSTED_PROXY:?set RM_TRUSTED_PROXY to the proxy CIDR}
|
||||
# Cookies are Secure by default; keep that. Override only for
|
||||
# local-HTTP smoke tests.
|
||||
# - RM_COOKIE_SECURE=true
|
||||
|
||||
volumes:
|
||||
rm-data:
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Running behind a reverse proxy
|
||||
|
||||
The restic-manager server is HTTP-only by design (see `spec.md` §11):
|
||||
TLS termination, public hostname, ACME, HSTS, and edge-level rate
|
||||
limiting all belong to a reverse proxy that you already operate
|
||||
outside this project. The reference compose in `deploy/docker-compose.yml`
|
||||
stands up *only* the server; this page covers what your proxy needs
|
||||
to do to make the rest of it work.
|
||||
|
||||
## What the proxy must forward
|
||||
|
||||
The server reads four headers when (and only when) the immediate peer
|
||||
matches `RM_TRUSTED_PROXY`:
|
||||
|
||||
| Header | Value | Why |
|
||||
|---------------------|----------------------------------------------------------|-----|
|
||||
| `X-Forwarded-For` | The original client IP (single value, or comma chain) | Rate-limit keys, audit log entries, and OIDC redirect-URI checks all use the real client IP. |
|
||||
| `X-Forwarded-Proto` | `https` | The server emits absolute URLs (e.g. OIDC redirect URIs) using this. |
|
||||
| `Host` | The public hostname clients use | Cookies are scoped to this; `RM_BASE_URL` must match. |
|
||||
| `Connection`/`Upgrade` | Pass through unchanged | The agent connects on `/ws/agent` and the live-log viewer connects on `/api/jobs/{id}/stream` — both are WebSockets and need `Upgrade: websocket` to survive the hop. |
|
||||
|
||||
Set `RM_TRUSTED_PROXY` to the CIDR (or comma-separated list of CIDRs)
|
||||
the proxy connects from. Anything outside that range has its
|
||||
`X-Forwarded-*` headers ignored, so a stray request that bypasses the
|
||||
proxy can't spoof the client IP.
|
||||
|
||||
## Example: Caddy
|
||||
|
||||
```caddyfile
|
||||
restic.example.com {
|
||||
# Caddy's default reverse_proxy preserves Host, sets
|
||||
# X-Forwarded-For/Proto, and passes Connection: upgrade through,
|
||||
# so a single directive covers HTTP + WebSocket.
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
|
||||
encode zstd gzip
|
||||
}
|
||||
```
|
||||
|
||||
`RM_TRUSTED_PROXY=127.0.0.1/32` if Caddy and the server share the
|
||||
host; the docker-bridge CIDR (commonly `172.16.0.0/12`) if Caddy
|
||||
runs in another container on the default bridge network.
|
||||
|
||||
## Example: nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name restic.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/restic.example.com.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/restic.example.com.key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket support — agent + live-log endpoints need this.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Trusted-proxy headers.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
|
||||
# Live job logs are long-running streams. Bump read timeouts
|
||||
# so nginx doesn't drop them mid-backup.
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
}
|
||||
}
|
||||
|
||||
# Standard websocket upgrade map (define once at the http {} level).
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
```
|
||||
|
||||
`RM_TRUSTED_PROXY` for the same-host case: `127.0.0.1/32`.
|
||||
|
||||
## Example: Traefik (label-based)
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.restic-manager.rule=Host(`restic.example.com`)"
|
||||
- "traefik.http.routers.restic-manager.entrypoints=websecure"
|
||||
- "traefik.http.routers.restic-manager.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.restic-manager.loadbalancer.server.port=8080"
|
||||
```
|
||||
|
||||
Traefik handles `X-Forwarded-*` and `Connection: upgrade` by default.
|
||||
`RM_TRUSTED_PROXY` should be the docker network the Traefik container
|
||||
shares with the server (commonly `172.16.0.0/12` for the default
|
||||
bridge, or whatever your overlay network's CIDR is).
|
||||
|
||||
## Sanity-checking the wiring
|
||||
|
||||
After bringing the stack up:
|
||||
|
||||
1. `curl -fsS https://restic.example.com/healthz` — should return 200.
|
||||
2. The login page should report HTTPS in the address bar; cookies
|
||||
set after login should carry the `Secure` flag.
|
||||
3. Check the server log for the `config resolved` line:
|
||||
`trusted_proxies` must include the IP/CIDR your proxy actually
|
||||
connects from.
|
||||
4. Enrol a test agent — the WebSocket handshake hitting `/ws/agent`
|
||||
confirms `Upgrade` is being forwarded correctly.
|
||||
|
||||
If any of those fail, the proxy is the first place to look — the
|
||||
server itself is intentionally minimal.
|
||||
@@ -332,7 +332,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
||||
- [ ] **P5-04** (S) Demo screenshots / short Loom walkthrough in README
|
||||
- [ ] **P5-05** (S) `SECURITY.md` with disclosure process
|
||||
- [ ] **P5-06** (M) End-to-end test suite in CI (Playwright vs. compose stack with sibling Linux agent)
|
||||
- [ ] **P5-07** (S) Reference deployment: `docker-compose.yml` + Caddyfile snippet showing the TLS-terminating reverse proxy in front of the HTTP-only server (also demonstrates `RM_TRUSTED_PROXY`)
|
||||
- [x] **P5-07** (S) Reference deployment landed alongside P5-03. `deploy/docker-compose.yml` stands up *only* the server (image-pinned via `RM_VERSION`, named volume for operator state, bound to localhost) — TLS termination is left to whichever reverse proxy the operator already runs. `docs/reverse-proxy.md` documents the headers + WebSocket pass-through the proxy must forward, the `RM_TRUSTED_PROXY` CIDR rule, and worked examples for Caddy, nginx, and Traefik.
|
||||
|
||||
### Phase 5 acceptance
|
||||
|
||||
|
||||
Reference in New Issue
Block a user