build(project): publish app on loopback and segment Docker networks

Bind the published port to 127.0.0.1 so the app is reachable only through the
host reverse proxy, not on the LAN/WAN — a 0.0.0.0 publish would also bypass
ufw/firewalld, since Docker's DNAT rules sit ahead of the host firewall.

Split the stack onto two networks with deterministic bridge names: `web`
(dk-tanabata) for the public-facing side, and `backend` (dk-tanabata-bnd,
internal:true) for the private app↔DB tier. The DB sits only on `backend`, which
has no gateway, so it has no route off-host.

Document TRUSTED_PROXIES and the loopback publish in .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 14:51:57 +03:00
parent 99668ec0d8
commit 5a05bb86e1
2 changed files with 46 additions and 4 deletions
+11 -1
View File
@@ -14,7 +14,10 @@
# DATABASE_URL at host.docker.internal (see the Database section below).
COMPOSE_PROFILES=with-db
# Host port the app is published on. The container always listens on 42776.
# Host port the app is published on, bound to 127.0.0.1 (loopback) — a reverse
# proxy on the host fronts it (see README → Reverse proxy). The container always
# listens on 42776. To expose the app directly without a proxy, drop the
# "127.0.0.1:" prefix on the ports line in docker-compose.yml.
APP_PORT=42776
# ---------------------------------------------------------------------------
@@ -51,6 +54,13 @@ JWT_SECRET=change-me-to-a-random-32-byte-secret
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h
# Reverse-proxy hops (comma-separated CIDRs/IPs) whose X-Forwarded-For is trusted,
# so the auth rate limiter sees real client IPs instead of the proxy's. The default
# covers loopback and the Docker bridge ranges a host nginx reaches the container
# through; widen/narrow it to match your proxy. Leave at the default for the
# standard "host nginx → 127.0.0.1" setup.
TRUSTED_PROXIES=127.0.0.1/32,::1/128,172.16.0.0/12
# Initial administrator, created on first startup if it does not yet exist.
# Changing the password later (via the API) is preserved across restarts.
ADMIN_USERNAME=admin
+35 -3
View File
@@ -35,10 +35,23 @@ services:
environment:
STATIC_DIR: /app/static
# The container always listens on 42776 (Dockerfile default); APP_PORT only
# changes the host-published port.
# Published on loopback only: a reverse proxy on the host (e.g. nginx) fronts
# the app and proxies to 127.0.0.1:${APP_PORT}. Binding to 127.0.0.1 keeps the
# app off the LAN/WAN — a plain "PORT:42776" would publish on 0.0.0.0 and, since
# Docker's DNAT rules sit ahead of the host firewall, bypass ufw/firewalld. The
# container always listens on 42776 (Dockerfile default); APP_PORT only changes
# the host-published port. Drop the 127.0.0.1 prefix if exposing it directly.
ports:
- "${APP_PORT:-42776}:42776"
- "127.0.0.1:${APP_PORT:-42776}:42776"
# Two-tier networking. `web` is the app's public-facing bridge (reached via the
# published loopback port above; it also provides egress, e.g. to a host
# Postgres via host.docker.internal). `backend` is the private tier the app
# uses to reach the bundled DB. The DB sits only on `backend`, so nothing on
# the host-facing side can reach it.
networks:
- web
- backend
# Wait for the bundled DB when the with-db profile is active. When using a
# host Postgres the db service is disabled, and required:false keeps this
@@ -76,6 +89,10 @@ services:
# the app at a Postgres running on the host instead.
profiles: ["with-db"]
# Private back-end tier only — never on `web`, never published.
networks:
- backend
environment:
POSTGRES_DB: ${POSTGRES_DB:-tanabata}
POSTGRES_USER: ${POSTGRES_USER:-tanabata}
@@ -97,6 +114,21 @@ services:
timeout: 5s
retries: 10
networks:
# Public-facing bridge for this app. The explicit bridge name (instead of
# Docker's random br-<hash>) makes it identifiable on the host for tcpdump and
# firewall rules.
web:
driver_opts:
com.docker.network.bridge.name: dk-tanabata
# Private back-end tier (app ↔ DB). internal:true drops the gateway so the DB
# has no route off-host. Note: Linux caps interface names at 15 chars, and
# dk-tanabata-bnd is exactly 15 — a longer app name would need a shorter suffix.
backend:
internal: true
driver_opts:
com.docker.network.bridge.name: dk-tanabata-bnd
volumes:
app_files:
app_thumbs: