feat: make "only the app publishes a port" a binding rule

A published database port puts the data on the public internet, and Docker's
own iptables rules bypass ufw — the server looks firewalled while the port is
open. Previously this was only a comment in compose.yaml, which an agent adding
a db service later is free to ignore.

Anchored at every point where a service could be added or shipped:
- rules/security.md: the rule, the reasoning, and the audit commands
- rules/backend.md + architecture SKILL: constraint when picking the data layer
- agents/backend-dev.md: non-negotiable for any service it adds
- agents/qa-engineer.md: a published port is a Critical finding
- skills/deploy: blocking pre-flight audit
- compose.yaml: db example now uses expose:, with depends_on healthcheck
- docs/DEPLOYMENT.md + README: SSH tunnel instead of publishing for debugging
This commit is contained in:
Florian Wilde
2026-08-09 01:12:06 +02:00
parent e30d02d41a
commit 75e99f75c6
9 changed files with 161 additions and 7 deletions
+4
View File
@@ -36,6 +36,10 @@ The non-negotiables:
as a fallback as a fallback
- Every new env var lands in `.env.example` in the same change - Every new env var lands in `.env.example` in the same change
- Whatever you add must run inside the Docker Compose stack - Whatever you add must run inside the Docker Compose stack
- **Any service you add to `compose.yaml` gets no `ports:` entry.** Only `app`
publishes a port. A database, cache or queue is reached by service name over the
compose network (`db:5432`) and must stay unreachable from the host. Publishing it
exposes the data to the internet and bypasses the server's firewall.
## Tests ## Tests
For each route handler, write a Vitest test next to it For each route handler, write a Vitest test next to it
+4
View File
@@ -33,6 +33,10 @@ The feature spec path and the list of previously deployed features to regression
fallback rate-limited? A missing or weakened passkey flow is a **High** finding. fallback rate-limited? A missing or weakened passkey flow is a **High** finding.
- Injection through every user-controlled input (XSS, SQL) - Injection through every user-controlled input (XSS, SQL)
- Secrets or internal detail leaking in API responses, logs or the network tab - Secrets or internal detail leaking in API responses, logs or the network tab
- **Published ports:** run `docker compose config | grep -B8 "published:"`. Only the
`app` service may publish, and only on `127.0.0.1`. A database, cache or queue with
a published port — or anything bound to `0.0.0.0` — is a **Critical** finding: it
exposes the service to the internet and bypasses the host firewall.
## Tests you write ## Tests you write
- **Unit (Vitest), co-located in `src/`:** hooks with real logic, pure transforms, - **Unit (Vitest), co-located in `src/`:** hooks with real logic, pure transforms,
+4
View File
@@ -18,6 +18,10 @@ decision exists, do not install an ORM or write schema code.
Whatever is chosen must be self-hostable inside the Docker Compose stack — Whatever is chosen must be self-hostable inside the Docker Compose stack —
the deployment target is a plain server, not a managed platform. the deployment target is a plain server, not a managed platform.
When you add it to `compose.yaml`, it gets **no published port**. The app reaches it
by service name over the compose network. See the port-exposure rule in
`.claude/rules/security.md`.
## Route Handlers ## Route Handlers
- Validate every request body with a Zod schema before touching it. Reuse the - Validate every request body with a Zod schema before touching it. Reuse the
+48
View File
@@ -59,6 +59,54 @@ Requirements whenever auth is present:
- Never interpolate user input into SQL, shell commands or file paths - Never interpolate user input into SQL, shell commands or file paths
- Escape or avoid `dangerouslySetInnerHTML` - Escape or avoid `dangerouslySetInnerHTML`
## Port exposure (mandatory)
**Only the app service publishes a port. Nothing else. Ever.**
Databases, caches, queues, search indexes, admin UIs and migration containers get
**no `ports:` entry at all**. Services reach each other by service name over the
Compose network — `postgres://user:pass@db:5432/app` works without publishing
anything. Use `expose:` if you want the port documented; it changes nothing about
reachability from outside.
```yaml
db:
image: postgres:17-alpine
expose:
- "5432" # documentation only — not reachable from the host
# ports: # NEVER. This would put the database on the public internet.
```
Why this is not a style preference: `ports: "5432:5432"` on a public server means
the only thing between an attacker and the whole dataset is the database password.
Worse, Docker writes its own nat/DOCKER-USER iptables rules that **bypass ufw** on a
typical setup — the server looks firewalled while the port is wide open. This is one
of the most common ways self-hosted stacks get breached.
The app service itself binds to loopback (`${APP_BIND:-127.0.0.1}`) because a reverse
proxy on the host terminates TLS and forwards to it. Binding `0.0.0.0` is only
correct when there is no proxy, and it means serving plain HTTP — which also breaks
passkeys.
If a port genuinely must be reachable for local debugging, bind it explicitly to
loopback and never bare:
```yaml
ports:
- "127.0.0.1:5432:5432" # ok for an SSH-tunnelled session
- "5432:5432" # WRONG — this is 0.0.0.0, the whole internet
```
Verify what is actually published before deploying:
```bash
docker compose config | grep -A3 "ports:" # what the merged config declares
docker compose ps --format "{{.Service}}\t{{.Ports}}" # what is really bound
```
Anything other than the app service showing a `0.0.0.0:` binding is a Critical
finding — treat it as such in `/qa` and block the deploy.
## Transport and Headers ## Transport and Headers
Security headers are configured in `next.config.ts`: `X-Frame-Options: DENY`, Security headers are configured in `next.config.ts`: `X-Frame-Options: DENY`,
+4 -2
View File
@@ -41,8 +41,10 @@ This template deliberately ships **no** database or ORM. Decide per feature:
> es eine Datenbank im Compose-Stack. > es eine Datenbank im Compose-Stack.
If a database is needed, also settle the ORM/driver, and record both in the If a database is needed, also settle the ORM/driver, and record both in the
Technical Decisions table. Constraint: it must be self-hostable in the Docker Technical Decisions table. Two constraints: it must be self-hostable in the Docker
Compose stack — no managed-only services. Compose stack (no managed-only services), and it runs **without a published port** —
only the app is reachable from outside, everything else talks over the internal
compose network.
### Authentication ### Authentication
+12
View File
@@ -36,6 +36,18 @@ Then check:
- [ ] Pending migrations are committed - [ ] Pending migrations are committed
- [ ] Everything committed and pushed to the remote the server pulls from - [ ] Everything committed and pushed to the remote the server pulls from
**Port audit — blocking.** Only the app service may publish a port, and only on
loopback:
```bash
docker compose config | grep -B8 "published:"
```
Any service other than `app` with a published port, or any binding to `0.0.0.0`,
stops the deploy. A published database port puts the data on the public internet and
bypasses ufw, because Docker writes its own iptables rules. Fix `compose.yaml`
before continuing.
## 3. First deployment only ## 3. First deployment only
Walk the user through this once. It happens on the server, over SSH. Walk the user through this once. It happens on the server, over SSH.
+32
View File
@@ -435,6 +435,37 @@ docker compose up -d --build # Erststart
./scripts/update.sh # neuen Stand ausrollen ./scripts/update.sh # neuen Stand ausrollen
``` ```
### Nur der App-Port geht nach außen
Im Stack veröffentlicht **ausschließlich der App-Service** einen Port, und auch den
nur auf Loopback (`127.0.0.1`). Jeder weitere Service — Datenbank, Cache, Queue,
Admin-UI — bekommt **keinen `ports:`-Eintrag**. Die Container erreichen sich über
den Compose-Netzwerknamen, dafür muss nichts veröffentlicht werden:
```
DATABASE_URL=postgres://user:pass@db:5432/app
```
> `ports: "5432:5432"` bindet auf `0.0.0.0` — die Datenbank steht damit im offenen
> Internet, geschützt nur durch ihr Passwort. Und Docker schreibt eigene
> iptables-Regeln, die **ufw umgehen**: Der Server sieht abgeschottet aus, während
> der Port offen ist. Das ist einer der häufigsten Wege, wie selbst gehostete Stacks
> kompromittiert werden.
Die Regel ist an vier Stellen verankert, damit sie beim Vibe-Coden nicht verloren
geht: `rules/security.md` (lädt automatisch beim Bearbeiten von `compose.yaml`), der
Backend-Sub-Agent, der QA-Sub-Agent (Verstoß = **Critical**) und der Pre-Flight von
`/deploy`, der den Deploy abbricht.
Prüfen, was wirklich gebunden ist:
```bash
docker compose config | grep -B8 "published:"
docker compose ps --format "{{.Service}}\t{{.Ports}}"
```
Zugriff zum Debuggen holst du dir per SSH-Tunnel, nicht per veröffentlichtem Port.
### Was `update.sh` macht ### Was `update.sh` macht
```mermaid ```mermaid
@@ -555,6 +586,7 @@ Vier Dinge, die nicht offensichtlich sind und je einmal wehtun:
| Fall | Was passiert | | Fall | Was passiert |
|------|--------------| |------|--------------|
| Ein DB-Service bekommt `ports: "5432:5432"` | Die Datenbank steht im offenen Internet — und Docker umgeht dabei ufw, der Server *wirkt* nur abgeschottet |
| `NEXT_PUBLIC_APP_URL` weicht von der Domain ab | Alle Passkeys scheitern, ohne verwertbare Fehlermeldung | | `NEXT_PUBLIC_APP_URL` weicht von der Domain ab | Alle Passkeys scheitern, ohne verwertbare Fehlermeldung |
| Port 3000 ist von einem anderen Projekt belegt | Playwright testet still gegen den fremden Server — `E2E_PORT` setzen | | Port 3000 ist von einem anderen Projekt belegt | Playwright testet still gegen den fremden Server — `E2E_PORT` setzen |
| Jemand legt eine `tailwind.config.ts` an | Wird in v4 ignoriert; Tokens gehören in `globals.css` | | Jemand legt eine `tailwind.config.ts` an | Wird in v4 ignoriert; Tokens gehören in `globals.css` |
+20 -2
View File
@@ -32,8 +32,13 @@ services:
max-file: "3" max-file: "3"
# A database is deliberately not part of this template — /architecture picks one # A database is deliberately not part of this template — /architecture picks one
# per project. When you add one, declare it here as a second service, put it on # per project.
# an internal network without a published port, and add its volume below. #
# RULE FOR EVERY SERVICE ADDED BELOW: only `app` gets a `ports:` entry. Databases,
# caches and queues get none — the app reaches them by service name over this
# compose network (`db:5432`). Publishing 5432 puts the database on the public
# internet, and Docker's iptables rules bypass ufw, so the server only *looks*
# firewalled. See .claude/rules/security.md.
# #
# db: # db:
# image: postgres:17-alpine # image: postgres:17-alpine
@@ -41,8 +46,21 @@ services:
# environment: # environment:
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} # POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
# POSTGRES_DB: ${POSTGRES_DB:-app} # POSTGRES_DB: ${POSTGRES_DB:-app}
# expose:
# - "5432" # documentation only — NOT reachable from the host
# volumes: # volumes:
# - db-data:/var/lib/postgresql/data # - db-data:/var/lib/postgresql/data
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U postgres"]
# interval: 10s
# timeout: 5s
# retries: 5
#
# Then make the app wait for it:
# app:
# depends_on:
# db:
# condition: service_healthy
# #
# volumes: # volumes:
# db-data: # db-data:
+33 -3
View File
@@ -82,14 +82,44 @@ to `HEALTH_TIMEOUT` seconds (default 120). If the container never turns healthy
prints the last 50 log lines, resets to the previous commit, rebuilds and restarts prints the last 50 log lines, resets to the previous commit, rebuilds and restarts
that — so a bad deploy self-heals instead of leaving the site down. that — so a bad deploy self-heals instead of leaving the site down.
## Port exposure
**Only the app service publishes a port, and only on loopback.** Everything else —
database, cache, queue, admin UI — gets no `ports:` entry. Services reach each other
by name over the Compose network, which needs nothing published:
```
DATABASE_URL=postgres://user:pass@db:5432/app
```
`ports: "5432:5432"` binds `0.0.0.0`, which means the database is on the public
internet. And Docker writes its own nat/DOCKER-USER iptables rules, so **ufw does not
block it** — the server looks firewalled while the port is open. This is a routine
way self-hosted stacks get breached.
Need access for debugging? Do not publish the port. Use an SSH tunnel:
```bash
ssh -L 5432:localhost:5432 user@server \
-t 'docker compose -f /srv/app/compose.yaml exec db psql -U postgres'
```
Audit what is really bound before and after every deploy:
```bash
docker compose config | grep -B8 "published:" # declared
docker compose ps --format "{{.Service}}\t{{.Ports}}" # actual
ss -tlnp | grep -v 127.0.0.1 # what the host exposes
```
## Database ## Database
The template ships none by design. When `/architecture` picks one: The template ships none by design. When `/architecture` picks one:
1. Add it as a second service in `compose.yaml` — no published port, reachable only 1. Add it as a second service in `compose.yaml` — **no `ports:` entry**, reachable
over the internal Compose network only over the internal Compose network. Use `expose:` if you want it documented.
2. Add a named volume for its data 2. Add a named volume for its data
3. Add `DATABASE_URL` to `.env.example` 3. Add `DATABASE_URL` to `.env.example` (pointing at the service name, e.g. `db:5432`)
4. Run migrations **before** the new container serves traffic. Either add a 4. Run migrations **before** the new container serves traffic. Either add a
short-lived `migrate` service that the app depends on, or add the migration short-lived `migrate` service that the app depends on, or add the migration
command to `scripts/update.sh` between build and restart. command to `scripts/update.sh` between build and restart.