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
13 changes: 13 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,16 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 5

# The postgres/redis/caddy image pins in docker-compose.yml, matching the
# sibling repos' compose tracking (the Dockerfile ecosystem above only reads
# FROM lines, not compose `image:` keys).
- package-ecosystem: docker-compose
directory: "/"
schedule:
interval: weekly
groups:
compose-images:
patterns:
- "*"
open-pull-requests-limit: 5
64 changes: 29 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,29 +486,37 @@ needs shared storage (RWX volume or object store) — a follow-on to the datasto
[`deploy/k8s/`](deploy/k8s/) and [`docker-compose.yml`](docker-compose.yml). Compose runs a
**single** app service always paired with its `postgres` database container. The default
Compose path also includes Redis for rate-limit buckets shared across uvicorn workers
(`ICEBERG_RATE_LIMIT_REDIS_URL=redis://redis:6379/0`) and exposes the app on loopback only:
(`ICEBERG_RATE_LIMIT_REDIS_URL=redis://redis:6379/0`) and a **Caddy TLS front end** on
`:80`/`:443` (same shape as the sibling IcebergTTX/EBS stacks); the app's plain-HTTP `:8000`
stays published on loopback only, as a local/debug side door:

```bash
docker compose up # app on http://localhost:8000 + PostgreSQL (no .env needed)
docker compose up # https://localhost via Caddy + PostgreSQL (no .env needed)
cp .env.example .env # optional: customise settings, then re-run
```

For the default `localhost` Caddy issues a certificate from its own local CA, so the browser
shows a one-time trust warning (or import Caddy's root CA); plain <http://localhost:8000>
also still works locally.

A fresh clone needs no pre-step — `.env` is optional (`env_file` is `required: false`) and is
merged in automatically when present. For production-style Compose, set
`ICEBERG_ENVIRONMENT=prod`, a real `ICEBERG_SECRET_KEY`, `ICEBERG_AUTO_MIGRATE=false`, and
non-default `POSTGRES_*` credentials.

### TLS / running behind a proxy
Iceberg always runs behind a **TLS-terminating reverse proxy** — a Kubernetes ingress, a cloud
load balancer, or (for a single-host Docker deployment) the opt-in **Caddy** profile:
load balancer, or (for a single-host Docker deployment) the bundled **Caddy** front end, which
Compose starts by default:

```bash
ICEBERG_DOMAIN=intel.example.com docker compose --profile tls up # auto Let's Encrypt TLS
ICEBERG_DOMAIN=intel.example.com docker compose up # auto Let's Encrypt TLS (needs public :80/:443 + DNS)
```

Caddy ([`deploy/Caddyfile`](deploy/Caddyfile)) terminates TLS and proxies to the app; pair it with
`ICEBERG_ENVIRONMENT=prod` for `Secure` cookies + HSTS. In this profile Caddy publishes
`:80`/`:443`, while the app's plain-HTTP `:8000` publish remains loopback-only. The container starts uvicorn with
`ICEBERG_ENVIRONMENT=prod` for `Secure` cookies + HSTS. Caddy publishes
`:80`/`:443` (the only public ports; it runs non-root with all capabilities dropped except
`NET_BIND_SERVICE`, on a read-only rootfs), while the app's plain-HTTP `:8000` publish remains loopback-only. The container starts uvicorn with
`--proxy-headers` and trusts `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS` (Compose scopes this
to its dedicated proxy network; Kubernetes requires the ingress pod CIDR) so the request scheme is correct and the **audit log records
the real client IP** rather than the proxy's. On **Kubernetes**, terminate TLS at an Ingress
Expand Down Expand Up @@ -663,7 +671,7 @@ flowchart TB
```

### Deployment topologies
Four supported shapes, from a zero-dependency local run to Kubernetes. Cylinders are persistent
Three supported shapes, from a zero-dependency local run to Kubernetes. Cylinders are persistent
storage; dashed links are config/credential injection.

**1. Local development** — uvicorn with the SQLite default and on-disk working dirs. No external
Expand All @@ -676,46 +684,32 @@ flowchart LR
App --> FS["Local dirs<br/>./attachments · ./figures · ./rendered"]
```

**2. Docker Compose (default)** — one app service paired with its own PostgreSQL container on the
compose network; only the app publishes a host port, bound to loopback (`localhost:8000`). Each
service has its own named volume.
**2. Docker Compose (default)** — a Caddy reverse proxy terminates TLS (Let's Encrypt for a real
`ICEBERG_DOMAIN`, a local-CA cert for the default `localhost`) and forwards `X-Forwarded-*`; the
app trusts those headers (`--proxy-headers`) so the scheme and client IP are correct. Caddy
publishes `:80`/`:443` — the only public ports; the app service pairs with its own PostgreSQL
container on the compose network and keeps a loopback-only plain-HTTP publish
(`localhost:8000`) as a local/debug side door. Each service has its own named volume.

```mermaid
flowchart TB
Client["Browser / API client"] -->|"localhost:8000"| App
Client["Browser / API client"] -->|"https :443 · http→https :80"| Caddy
Client -.->|"localhost:8000 (loopback debug)"| App

subgraph net["docker compose — default bridge network"]
App["iceberg service<br/>FastAPI + uvicorn --proxy-headers<br/>container :8000"]
subgraph net["docker compose — iceberg-internal network"]
Caddy["caddy service<br/>TLS termination (Let's Encrypt / local CA)<br/>:80 · :443 · non-root, read-only"]
App["iceberg service<br/>FastAPI + uvicorn --proxy-headers · :8000<br/>prod → Secure cookies + HSTS"]
PG["postgres service<br/>postgres:17 · :5432 (network-internal)"]
Caddy -->|"reverse proxy + X-Forwarded-*"| App
App -->|"postgresql+psycopg://iceberg@postgres:5432"| PG
end

Caddy --- CV[("caddy-data · caddy-config<br/>certificates")]
App --- V1[("iceberg-data volume<br/>/data: attachments · figures · rendered")]
PG --- V2[("iceberg-pg-data volume<br/>/var/lib/postgresql/data")]
```

**3. Docker Compose + TLS (`--profile tls`)** — adds a Caddy reverse proxy that terminates TLS and
forwards `X-Forwarded-*`; the app trusts those headers (`--proxy-headers`) so the scheme and client
IP are correct. Caddy publishes `:80`/`:443`; the app's plain-HTTP publish remains loopback-only.

```mermaid
flowchart TB
Client["Browser"] -->|"https :443 · http→https :80"| Caddy

subgraph net["docker compose --profile tls"]
Caddy["caddy service<br/>TLS termination (Let's Encrypt / local CA)<br/>:80 · :443"]
App["iceberg service<br/>uvicorn --proxy-headers · :8000<br/>prod → Secure cookies + HSTS"]
PG["postgres service · :5432"]
Caddy -->|"reverse proxy + X-Forwarded-*"| App
App -->|"psycopg"| PG
end

Caddy --- CV[("caddy-data · caddy-config<br/>certificates")]
App --- V1[("iceberg-data<br/>/data")]
PG --- V2[("iceberg-pg-data")]
```

**4. Kubernetes** — an Ingress terminates TLS to a ClusterIP Service and the single-replica
**3. Kubernetes** — an Ingress terminates TLS to a ClusterIP Service and the single-replica
Deployment (`Recreate`, non-root, read-only rootfs). A migrate Job runs `alembic upgrade head` out
of band; config comes from a ConfigMap (non-secret) and the `ICEBERG_DATABASE_URL`/secrets from a
Secret. PostgreSQL is a managed instance or the optional StatefulSet; uploads/renders live on a
Expand Down
24 changes: 17 additions & 7 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Caddy reverse proxy for a single-host Docker deployment (opt-in `tls` profile;
# see docker-compose.yml). Set ICEBERG_DOMAIN to your public hostname for
# automatic Let's Encrypt TLS; it defaults to `localhost`, for which Caddy issues
# a local-CA certificate (handy for a demo). Caddy forwards X-Forwarded-For/-Proto
# by default, which the app honours via uvicorn's --proxy-headers (so the real
# client IP reaches the audit log and the scheme is correct for Secure cookies).
# Caddy reverse proxy — the default front end for a single-host Docker
# deployment (see docker-compose.yml). Set ICEBERG_DOMAIN to your public
# hostname for automatic Let's Encrypt TLS; it defaults to `localhost`, for
# which Caddy issues a local-CA certificate (handy for a demo or local run).
# Caddy forwards X-Forwarded-For/-Proto by default, which the app honours via
# uvicorn's --proxy-headers (so the real client IP reaches the audit log and
# the scheme is correct for Secure cookies).
{$ICEBERG_DOMAIN:localhost} {
# Proxy-level request-body cap. Keep this at/above ICEBERG_MAX_BODY_MB (default
# 30 MB, itself just above ICEBERG_ATTACHMENT_MAX_MB=25 MB) so real uploads
Expand All @@ -12,5 +13,14 @@
request_body {
max_size 30MB
}
reverse_proxy iceberg:8000
# Security headers (CSP, HSTS, X-Frame-Options, …) are emitted by the app's
# SecurityHeaders middleware — the single source of truth — so Caddy adds
# nothing here. The transport timeouts bound a hung upstream so a stalled app
# worker returns 502 instead of holding client connections open indefinitely.
reverse_proxy iceberg:8000 {
transport http {
dial_timeout 10s
response_header_timeout 60s
}
}
}
75 changes: 60 additions & 15 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
# zero-dependency local dev/test default only; it is NOT a container deployment
# option — the app refuses to boot on SQLite when ICEBERG_ENVIRONMENT=prod.)
#
# docker compose up # app on localhost:8000 + PostgreSQL
# docker compose up # https://localhost via Caddy (local-CA cert) + PostgreSQL
# ICEBERG_DOMAIN=intel.example.com \
# docker compose --profile tls up # add a Caddy TLS reverse proxy on :80/:443
# docker compose up # automatic Let's Encrypt TLS on the real domain
#
# A single app service always pairs with the `postgres` service, so no two
# services ever bind the same host port.
# Caddy is the default public entry point (:80/:443), matching the sibling
# IcebergTTX/EBS stacks; the app's plain-HTTP :8000 stays published on loopback
# only as a local/debug side door. A single app service always pairs with the
# `postgres` service, so no two services ever bind the same host port.
services:
iceberg:
build: .
Expand Down Expand Up @@ -85,31 +87,74 @@ services:
retries: 10
networks: [iceberg-internal]

# Opt-in TLS reverse proxy for a single-host Docker deployment:
# ICEBERG_DOMAIN=intel.example.com docker compose --profile tls up
# Caddy terminates TLS (automatic Let's Encrypt for a real domain; a local-CA
# cert for the default `localhost`) and proxies to the app over the compose
# network. For real production also set ICEBERG_ENVIRONMENT=prod in .env
# (Secure cookies + HSTS), ICEBERG_AUTO_MIGRATE=false, a real
# ICEBERG_SECRET_KEY and non-default Postgres credentials. The app's plain HTTP
# port is bound to loopback, so Caddy is the public entry point.
# TLS reverse proxy — the default front end for a single-host Docker
# deployment (same shape as the sibling IcebergTTX/EBS stacks). Caddy
# terminates TLS (automatic Let's Encrypt for a real ICEBERG_DOMAIN; a
# local-CA cert for the default `localhost`) and proxies to the app over the
# compose network. For real production also set ICEBERG_ENVIRONMENT=prod in
# .env (Secure cookies + HSTS), ICEBERG_AUTO_MIGRATE=false, a real
# ICEBERG_SECRET_KEY and non-default Postgres credentials. The app's plain
# HTTP port is bound to loopback, so Caddy is the public entry point.
caddy-init:
# One-shot: the named cert/config volumes initialise root-owned from the
# image, so chown them for the non-root caddy user below (k8s does the same
# job with fsGroup). Runs and exits before caddy starts.
image: caddy:2-alpine
user: "0:0"
entrypoint: ["/bin/sh", "-c", "chown -R 1000:1000 /data /config"]
volumes:
- caddy-data:/data
- caddy-config:/config
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
# Caddy creates data/caddy/{certificates,locks,pki} mode 0700 owned by uid
# 1000. Root cannot descend into them without its DAC bypass, which
# cap_drop ALL removed — so on every run after the first, chown -R fails
# EPERM without DAC_READ_SEARCH.
- DAC_READ_SEARCH
restart: "no"
networks: [iceberg-internal]

caddy:
profiles: ["tls"]
image: caddy:2
image: caddy:2-alpine
restart: unless-stopped
# Non-root (the official image defaults to root): run as uid 1000. The caddy
# binary ships the cap_net_bind_service file capability, so NET_BIND_SERVICE
# must stay in the bounding set (cap_add below) — both so exec of the setcap
# binary succeeds under cap_drop ALL and so the non-root process can bind
# :80/:443.
user: "1000:1000"
depends_on:
iceberg:
condition: service_healthy
caddy-init:
condition: service_completed_successfully
environment:
# localhost (local-CA self-signed) | your.domain.com (Let's Encrypt)
ICEBERG_DOMAIN: ${ICEBERG_DOMAIN:-localhost}
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-data:/data # issued certs — persist across restarts (required)
- caddy-config:/config
networks: [iceberg-internal]
# Read-only rootfs with the cert/config stores on volumes and a writable /tmp.
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
read_only: true
tmpfs:
- /tmp

networks:
iceberg-internal:
Expand Down
20 changes: 18 additions & 2 deletions tests/test_prod_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,6 @@ def test_compose_prod_overrides_and_loopback_port(tmp_path):
str(env_file),
"-f",
str(_REPO_ROOT / "docker-compose.yml"),
"--profile",
"tls",
"config",
"--format",
"json",
Expand Down Expand Up @@ -139,3 +137,21 @@ def test_compose_prod_overrides_and_loopback_port(tmp_path):
"protocol": "tcp",
}
]

# Caddy is the default front end (no profile flag needed): sole public
# ports, non-root, minimal capabilities, gated on a healthy app.
caddy = config["services"]["caddy"]
assert {(p["target"], p["protocol"]) for p in caddy["ports"]} == {
(80, "tcp"),
(443, "tcp"),
(443, "udp"),
}
assert all("host_ip" not in p for p in caddy["ports"]) # public, not loopback
assert caddy["user"] == "1000:1000"
assert caddy["cap_drop"] == ["ALL"]
assert caddy["cap_add"] == ["NET_BIND_SERVICE"]
assert caddy["read_only"] is True
assert caddy["depends_on"]["iceberg"]["condition"] == "service_healthy"
assert (
caddy["depends_on"]["caddy-init"]["condition"] == "service_completed_successfully"
)