Docker-Anleitungen enden meistens bei docker compose up -d. Danach beginnt der Teil, der über Monate entscheidet: Wie kommt der Stack nach einem Neustart wieder hoch? Wo liegen die Daten? Warum ist die Datenbank aus dem Internet erreichbar, obwohl die Firewall zu ist? Und warum ist die Platte voll?
Die Compose-Datei als Dokumentation#
Eine gute Compose-Datei erklärt sich selbst. Das hier ist das Grundgerüst, an dem sich der Rest des Beitrags entlanghangelt:
name: appstack
services:
app:
image: ghcr.io/beispiel/app:1.8.2 # feste Version, kein :latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://app:${DB_PASSWORT}@db:5432/app
networks: [intern, web]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
db:
image: postgres:17.4-alpine
restart: unless-stopped
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORT}
volumes:
- db-daten:/var/lib/postgresql/data
networks: [intern] # bewusst kein Zugang zum Web-Netz
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
proxy:
image: caddy:2.10-alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80" # nur lokal, davor der Host-Proxy
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-daten:/data
networks: [web]
networks:
intern:
internal: true # kein Weg ins Internet
web:
volumes:
db-daten:
caddy-daten:
Ports: der Fehler, der am meisten kostet#
ports: - "5432:5432" bindet auf allen Adressen. Docker schreibt dafür eigene Regeln in die nat-Tabelle und umgeht damit ufw, die Firewall meldet weiterhin, der Port sei zu, während die Datenbank aus dem Internet erreichbar ist.
Drei Regeln, die das Problem beseitigen:
- Was nur intern gebraucht wird, bekommt gar kein
ports. Container im selben Netz erreichen sich über den Dienstnamen (db:5432). Ein veröffentlichter Port ist dafür nicht nötig. - Was lokal erreichbar sein muss, wird an
127.0.0.1gebunden."127.0.0.1:8080:80". - Was von außen erreichbar sein muss, steht hinter einem Reverse-Proxy auf dem Host, dann ist genau ein Port offen, und TLS liegt an einer Stelle.
Prüfen, was tatsächlich lauscht:
sudo ss -tulpn | grep docker
docker compose ps --format 'table {{.Service}}\t{{.Ports}}'
Netze trennen#
internal: true nimmt einem Netz den Weg nach draußen. Container darin erreichen einander, aber nicht das Internet. Für eine Datenbank ist das die richtige Einstellung: Sie muss nichts nachladen, und wenn sie es versucht, will man das wissen.
Der Anwendungscontainer hängt in beiden Netzen und ist damit die einzige Brücke. Das ist keine harte Sicherheitsgrenze, aber es macht aus einem „die Datenbank hat Internet“ ein bewusstes, sichtbares Detail.
Secrets gehören nicht in die Compose-Datei#
Die Compose-Datei gehört ins Versionsverwaltungssystem. Passwörter nicht.
# .env neben der compose.yaml — und in .gitignore
cat > .env <<EOF
DB_PASSWORT=$(openssl rand -base64 32)
EOF
chmod 600 .env
# .gitignore
.env
Für mehr als eine Handvoll Geheimnisse gibt es secrets: in Compose, die als Dateien unter /run/secrets/ eingehängt werden, dann steht das Passwort nicht in der Prozessumgebung, wo es jedes docker inspect zeigt.
Die .env ist die Datei, die beim Wiederherstellen am häufigsten fehlt, sie ist nicht im Git und nicht im Volume. Sie gehört ausdrücklich in die Backup-Liste.
Volumes: benannt statt Bind-Mount#
Für Daten, die dem Container gehören (Datenbankdateien, Caddy-Zertifikate), sind benannte Volumes richtig: Docker verwaltet Rechte und Pfad.
Für Dinge, die du bearbeitest (Konfigurationsdateien), ist ein Bind-Mount richtig, dann liegt die Datei sichtbar neben der Compose-Datei und ist versionierbar. Mit :ro, wenn der Container sie nur lesen soll.
Sichern lässt sich ein Volume ohne Wissen über sein Innenleben:
docker run --rm \
-v appstack_db-daten:/quelle:ro \
-v "$PWD":/ziel \
alpine tar czf /ziel/db-daten-$(date +%F).tar.gz -C /quelle .
Für Datenbanken ist ein Dump trotzdem besser als ein Dateikopie, die Begründung steht in Backup-Strategie 3-2-1:
docker compose exec -T db pg_dump -U app app | zstd -9 > db-$(date +%F).sql.zst
Healthchecks sind nicht Kosmetik#
Ohne Healthcheck weiß Docker nur, ob der Prozess läuft, nicht, ob der Dienst antwortet. Mit Healthcheck bekommt man zwei Dinge:
depends_onmitcondition: service_healthywartet wirklich, statt nur die Startreihenfolge zu beeinflussen.- Ein hängender Container ist als
unhealthysichtbar, statt scheinbar zu laufen.
docker compose ps # Spalte STATUS zeigt (healthy) / (unhealthy)
start_period ist der Wert, den man beim ersten Mal vergisst: Eine Anwendung, die 30 Sekunden zum Starten braucht, gilt sonst währenddessen als krank und wird bei aktivem Neustart-Verhalten in einer Schleife neu gestartet.
Logs begrenzen, bevor die Platte voll ist#
Docker schreibt Container-Ausgaben standardmäßig unbegrenzt nach /var/lib/docker/containers/. Ein gesprächiger Dienst füllt damit über Monate die Systemplatte und der Ausfall kommt dann als „nichts geht mehr“, nicht als „Logs zu groß“.
Pro Dienst über logging: wie oben, oder einmal global:
// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
sudo systemctl restart docker
Die globale Einstellung wirkt nur auf neu erstellte Container, bestehende behalten ihre alte Konfiguration bis zum nächsten docker compose up --force-recreate.
Beim Booten starten: systemd statt restart: always#
restart: unless-stopped bringt Container nach einem Docker-Neustart zurück. Für einen sauberen Start des ganzen Stacks nach einem Server-Neustart, inklusive Abhängigkeit von gemounteten Dateisystemen, ist ein systemd-Unit ehrlicher:
# /etc/systemd/system/appstack.service
[Unit]
Description=appstack (docker compose)
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/appstack
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
ExecReload=/usr/bin/docker compose up -d --remove-orphans
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now appstack
sudo systemctl status appstack
Der Gewinn: Ein systemctl stop appstack hält den Stack wirklich an, und ein Neustart der Maschine bringt ihn in definierter Reihenfolge zurück.
Aufräumen, aber gezielt#
# Was belegt überhaupt Platz?
docker system df -v
# Ungenutzte Images und Build-Cache, ohne Volumes anzufassen
docker image prune -a --filter "until=168h"
docker builder prune --filter "until=168h"
docker system prune -a --volumes löscht auch benannte Volumes, die gerade zu keinem laufenden Container gehören. Ein Stack, der zum Aufräumzeitpunkt gestoppt ist, verliert damit seine Datenbank. Das ist einer der wenigen Docker-Befehle, bei denen man die Optionen einzeln lesen sollte.
Der Zustand, den man anstrebt#
docker compose pszeigt alle Dienste alshealthy.ss -tulpnzeigt außer dem Reverse-Proxy keinen veröffentlichten Port auf0.0.0.0.- Ein
rebootbringt den Stack ohne Handgriff zurück. - Die Platte wächst nicht monoton.
.envund Volumes liegen im Backup, und das wurde einmal zurückgespielt.
Wie es weitergeht, wenn Images altern und Updates anstehen: Container aktuell halten.
Geschrieben aus dem laufenden Betrieb: MRMedia betreibt eigene Proxmox- Hosts, einen Backup-Server und Kundendienste in der EU. Fehler in einer Anleitung sind Fehler im eigenen Betrieb. Korrekturen bitte über Discord.