Skip to content

IT Docker

IT-Master Heizmann edited this page Sep 18, 2026 · 2 revisions

Docker

Caution

Diese Seite richtet sich ausschliesslich an Informatikerinnen und Informatiker. Nicht für Laien geeignet: Falsch eingebundene Volumes führen zu Datenverlust beim nächsten Container-Neubau; falsche Proxy-Einstellungen sperren alle Anfragen oder hebeln Rate-Limits aus. Ohne Fachkenntnisse bitte Installation Schritt für Schritt verwenden.

Für wen ist diese Seite? Für Informatikerinnen und Informatiker, die SmallTime 2027 mit Docker oder Docker Compose betreiben. Grundlage sind backend/Dockerfile, backend/docker-entrypoint.sh, frontend/Dockerfile und frontend/nginx.conf im Repository.

Inhalt

  1. Die Images im Repository
  2. Hinweise zur mitgelieferten nginx.conf
  3. Pfade im Backend-Container
  4. Volumes
  5. Umgebungsvariablen
  6. Beispiel: docker-compose.yml
  7. Beispiel: nginx.conf für den Frontend-Container
  8. Betrieb
  9. Alternative: ein Container mit dem Betriebspaket

Die Images im Repository

Beide Dockerfiles erwarten den Repository-Stamm als Build-Kontext (sie kopieren backend/… bzw. frontend/…):

docker build -f backend/Dockerfile  -t smalltime-backend:1.0.0  .
docker build -f frontend/Dockerfile -t smalltime-frontend:1.0.0 .

backend/Dockerfile (Multi-Stage):

Stage Basis Inhalt
build node:26.9.0-alpine npm ci, npm run build (esbuild → dist/server.js, dist/migrate.js)
runtime node:26.9.0-alpine NODE_ENV=production, npm ci --omit=dev, dist/ und docker-entrypoint.sh, leere Ordner config/, data/, uploads/; EXPOSE 55000

docker-entrypoint.sh:

#!/bin/sh
set -eu
node dist/migrate.js
exec node dist/server.js

Die Migrationen laufen also vor dem Serverstart (und beim Start ein zweites Mal ohne Wirkung). exec sorgt dafür, dass Node PID 1 ist und SIGTERM erhält.

frontend/Dockerfile (Multi-Stage):

Stage Basis Inhalt
build node:26.9.0-alpine npm ci, npm run build; kopiert backend/config mit, weil vite.config.ts daraus Ports und Upload-Endungen liest
runtime nginx:1.27-alpine frontend/nginx.conf, Build nach /usr/share/nginx/html; EXPOSE 80

Die mitgelieferte nginx.conf liefert das SPA aus (try_files … /index.html), setzt Sicherheitsheader und leitet /api an http://backend:55000 weiter. Der Backend-Service muss deshalb backend heissen.

Das Backend-Image enthält kein Frontend. Unter / antwortet es mit dem Hinweis «Kein gebautes Frontend gefunden». Im Zwei-Container-Betrieb liefert nginx das Frontend.


Hinweise zur mitgelieferten nginx.conf

frontend/nginx.conf enthält seit Version 1.0.0:

  • Upgrade/Connection-Header für den WebSocket /api/live,
  • location = /custom.css (Weiterleitung ans Backend für data/custom.css),
  • frame-src 'self' blob: in der CSP (PDF-Vorschau),
  • client_max_body_size 20m (Uploads).

Verbleibende Unterschiede:

Punkt Folge Abhilfe
CSP mit style-src 'unsafe-inline' statt Nonce schwächer als die CSP des Backends; der Platzhalter __CSP_NONCE__ in index.html wird nicht ersetzt hinnehmen oder Ein-Container-Variante
X-Forwarded-For $proxy_add_x_forwarded_for hinter einem weiteren Proxy Backend (trust proxy = 1) sieht als Client-IP die IP des äusseren Proxys → alle teilen sich Rate-Limits real_ip-Modul, siehe Beispiel

Pfade im Backend-Container

WORKDIR ist /app/backend. Weil im Image weder frontend/ noch public/ existiert, greift die Fallback-Auflösung:

Zweck Pfad im Container
Konfiguration /app/backend/config (backend.json, security.json, optional frontend.json)
Datenbank, Backups, Dokumente, custom.css /app/backend/data
Uploads /app/backend/uploads
Import aus SmallTime PHP /app/convert
.env (optional) /app/backend/.env

Warning

Das Image enthält keine config/backend.json und config/security.json. Beim ersten Start schreibt das Backend security.json mit den Code-Standards: Länderfilter aus (allowedCountries: []), allowRegistration: false, allowLocalhost: true. Upload-Endungen ohne backend.json: nur .bmp .csv .gif .jpeg .jpg .json .md .png .txt .webp (kein PDF/Office). Deshalb vorbereitete Dateien ins Volume legen.


Volumes

Volume Container-Pfad Pflicht
Daten /app/backend/data ja – Datenbank, tägliche Backups, Dokumente
Uploads /app/backend/uploads ja
Konfiguration /app/backend/config ja – sonst gehen Änderungen aus Administration → Sicherheit beim Neubau verloren
Import /app/convert nur für die Übernahme aus SmallTime PHP

Das Image läuft als root (kein USER im Dockerfile). Bind-Mounts gehören damit root; wer einen unprivilegierten Benutzer erzwingt (user: "1000:1000"), muss die Host-Verzeichnisse entsprechend besitzen lassen.

SQLite im WAL-Modus nicht auf Netzwerk-Volumes (NFS/SMB/CIFS) legen.


Umgebungsvariablen

Vollständige Liste: Konfiguration. Für Docker relevant:

Variable Wert im Beispiel Bemerkung
NODE_ENV production im Image bereits gesetzt
SESSION_SECRET aus .env des Compose-Projekts Pflicht, sonst Startabbruch
FRONTEND_ORIGIN https://zeit.example.ch öffentliche Adresse; Lizenz-Domain
TRUST_PROXY true nginx-Container ist der Proxy (ein Hop)
SECURE_COOKIE true TLS endet vor dem nginx-Container; X-Forwarded-Proto ist dort http
ENABLE_COMPRESSION true Standard in production
BCRYPT_COST 12
REDIS_URL redis://redis:6379 optional
PORT – Standard 55000; nginx erwartet 55000
SMALLTIME_AUTO_IMPORT – false schaltet den Import aus /app/convert ab

Beispiel: docker-compose.yml

Important

Beispiel, nicht Teil des Repositorys und nicht getestet. An die eigene Umgebung anpassen. Vorausgesetzt ist ein TLS-terminierender Reverse-Proxy auf dem Host (nginx, Caddy, Traefik …), der auf 127.0.0.1:8080 weiterleitet.

Verzeichnisstruktur neben dem Repository-Stamm:

SmallTime_2027.01/
├── docker-compose.yml
├── .env                       SESSION_SECRET=…  (nur für Compose, nicht ins Image)
├── docker/nginx.conf          siehe nächster Abschnitt
└── volumes/
    ├── config/backend.json    Kopie von backend/config/backend.json
    ├── config/security.json   vorbereitet, siehe unten
    ├── data/
    ├── uploads/
    └── convert/               optional
# docker-compose.yml – BEISPIEL
name: smalltime

services:
  backend:
    build:
      context: .
      dockerfile: backend/Dockerfile
    image: smalltime-backend:1.0.0
    restart: unless-stopped
    environment:
      NODE_ENV: production
      FRONTEND_ORIGIN: https://zeit.example.ch
      SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET fehlt in .env}
      TRUST_PROXY: "true"
      SECURE_COOKIE: "true"
      ENABLE_COMPRESSION: "true"
      BCRYPT_COST: "12"
      # REDIS_URL: redis://redis:6379
    volumes:
      - ./volumes/config:/app/backend/config
      - ./volumes/data:/app/backend/data
      - ./volumes/uploads:/app/backend/uploads
      - ./volumes/convert:/app/convert
    expose:
      - "55000"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:55000/api/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 60s
    stop_grace_period: 20s
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

  frontend:
    build:
      context: .
      dockerfile: frontend/Dockerfile
    image: smalltime-frontend:1.0.0
    restart: unless-stopped
    depends_on:
      backend:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro

  # redis:
  #   image: redis:7-alpine
  #   restart: unless-stopped
  #   command: ["redis-server", "--save", "", "--appendonly", "no"]

volumes/config/security.json (ohne Länder-Header vom Proxy):

{
  "rateLimits": {
    "login": { "limit": 5, "windowMinutes": 5 },
    "passwordChange": { "limit": 5, "windowMinutes": 15 },
    "upload": { "limit": 20, "windowMinutes": 10 }
  },
  "allowedCountries": [],
  "countryHeaders": ["CF-IPCountry", "CloudFront-Viewer-Country", "X-Country-Code", "X-Geo-Country", "X-Country"],
  "allowedIpRanges": [],
  "blockedIpRanges": [],
  "allowRegistration": false,
  "allowLocalhost": false,
  "bcryptCost": 12
}

Note

Mit allowLocalhost: false und einem aktiven Länderfilter würde auch der Healthcheck (127.0.0.1) mit 403 abgewiesen. Mit leerer Länderliste ist der Healthcheck unproblematisch.

Start:

docker compose build
docker compose up -d
docker compose logs -f backend

Erstanmeldung: admin / admin1234 (Passwortwechsel erzwungen), sofern kein Import aus volumes/convert erfolgt ist.


Beispiel: nginx.conf für den Frontend-Container

Important

Beispiel, nicht getestet. Ersetzt frontend/nginx.conf per Volume, zusätzlich mit real_ip für einen äusseren Proxy. Die Adressbereiche bei set_real_ip_from an das Docker-Netz bzw. den äusseren Proxy anpassen.

# docker/nginx.conf – BEISPIEL
map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

server {
  listen 80;
  server_name _;

  root /usr/share/nginx/html;
  index index.html;

  client_max_body_size 55m;

  # Echte Client-IP vom äusseren Reverse-Proxy übernehmen (Host-Proxy kommt über das Docker-Gateway)
  set_real_ip_from 172.16.0.0/12;
  set_real_ip_from 127.0.0.1;
  real_ip_header X-Forwarded-For;
  real_ip_recursive on;

  add_header X-Content-Type-Options "nosniff" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  add_header X-Frame-Options "SAMEORIGIN" always;
  add_header Cross-Origin-Opener-Policy "same-origin" always;
  add_header Cross-Origin-Resource-Policy "same-origin" always;
  add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
  add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; frame-src 'self' blob:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self'; media-src 'self'; manifest-src 'self'" always;

  location = /api/live {
    proxy_pass http://backend:55000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        $connection_upgrade;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-For   $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_read_timeout 1h;
  }

  location /api {
    proxy_pass http://backend:55000;
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-For   $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
  }

  location = /custom.css {
    proxy_pass http://backend:55000;
    proxy_set_header Host $host;
  }

  location / {
    try_files $uri $uri/ /index.html;
  }
}

X-Forwarded-Proto https ist fest gesetzt, weil TLS vor diesem Container endet. Der äussere Proxy muss Host durchreichen (Lizenzprüfung vergleicht den Hostnamen).


Betrieb

# Status / Logs
docker compose ps
docker compose logs --since 1h backend

# Online-Backup der Datenbank (sqlite3 ist im Image nicht enthalten → vom Host aus)
sqlite3 volumes/data/app.sqlite ".backup 'backup/app-$(date +%F).sqlite'"

# Update
git pull
docker compose build
docker compose up -d        # Migrationen laufen im Entrypoint
  • Tägliche Backups (mit automatischer Rotation) schreibt die App nach volumes/data/backups/ (siehe Sicherheit und Betrieb).
  • Vor jedem Update Volumes sichern; Migrationen sind nur vorwärts.
  • Nur eine Backend-Instanz betreiben (deploy.replicas: 1, kein Scaling).
  • Import aus SmallTime PHP: Daten nach volumes/convert/ legen, bevor das Backend zum ersten Mal mit leerem volumes/data/ startet. Bericht danach in volumes/convert/conversion-report.md.

Alternative: ein Container mit dem Betriebspaket

Statt zweier Container kann das mit prod.cmd erzeugte Betriebspaket (dist/ im Repository-Stamm) in einem einzigen Node-Container laufen. Das Backend liefert dann Frontend, /custom.css und WebSocket selbst aus, mit der vollständigen CSP (Nonce, frame-src blob:). Davor genügt ein TLS-Proxy wie auf Reverse-Proxy und HTTPS beschrieben.

# Dockerfile.dist – BEISPIEL, nicht im Repository. Build-Kontext: dist/ aus prod.cmd
FROM node:26.9.0-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm install --omit=dev && npm cache clean --force
COPY app.cjs ./
COPY dist ./dist
COPY public ./public
COPY config ./config.default
RUN mkdir -p config data uploads
EXPOSE 55000
CMD ["node", "app.cjs"]

Volumes dann: /app/config (mit dem Inhalt von config.default vorbefüllen), /app/data, /app/uploads, /app/convert. Die .env aus dem Paket nicht ins Image kopieren; Variablen per environment: übergeben.


Weiter mit: Konfiguration · Reverse-Proxy und HTTPS · Sicherheit und Betrieb

Clone this wiki locally