Files
ske087 46602f1933 Sanitize codebase, reorganize docs, and add missing deploy files
Remove dead code identified in docs/SANITIZATION-REVIEW.md:
- app/blueprints/content_old.py and app/blueprints/playlist.py
- app/models/group.py, app/utils/nginx_config_reader.py
- orphaned templates (content_list, edit_content, upload_content,
  player_page) and the related group/Template references

Result: 6 blueprints, 82 routes, no dead modules or orphan templates.

Add files that deploy.sh and docker-entrypoint.sh already require but
which were never tracked:
- https_manager.py       (referenced by deploy.sh, migrate_network.sh,
                          docker-entrypoint.sh)
- Caddyfile.example      (seeded by deploy.sh; its absence aborts deploy)

Relocate generated Graphify artifacts from graphify-out/ to
docs/graphify-out/ (110 files, no content change) and archive the
superseded docs under docs/.

Ignore hygiene:
- ignore ad-hoc .env backups (.env.bak*) — they contain live secrets
- keep the pre-sanitization snapshots (docs/legacy code/,
  docs/old_code_documentation/) on disk but out of the repo

Fix .env.example: drop a duplicated config block, genericize the
hardcoded host IP, and document HOSTNAME_INTERNAL.
2026-09-11 12:18:34 +03:00

15 KiB
Raw Permalink Blame History

07 · Deployment

DigiServer v2 runs as a Docker Compose stack (app + Caddy) with optional remote SSH provisioning of players.


1. Architecture

flowchart LR
    subgraph Host["Docker Host"]
        subgraph Net["digiserver-network"]
            APP["digiserver-app\nFlask + Gunicorn :5000"]
            CAD["caddy:2-alpine\n:80 / :443"]
        end
        VOL1["./data/instance → /app/instance\n(SQLite DBs)"]
        VOL2["./data/uploads → /app/app/static/uploads"]
        VOL3["./data/Caddyfile → /etc/caddy/Caddyfile"]
        VOL4["./data/caddy-data → /data\n./data/caddy-config → /config"]
    end
    Browser["Browser"] --> CAD
    CAD --> APP
    APP --> VOL1
    APP --> VOL2
    CAD --> VOL3
    CAD --> VOL4
    APP -.SSH/rsync.-> Players["Remote player hosts"]

2. docker-compose.yml

Service digiserver-app

  • Build: . (Dockerfile, python:3.13-slim)
  • Ports: 5000:5000 (direct dev access; also expose: 5000)
  • Volumes: ./data/instance:/app/instance, ./data/uploads:/app/app/static/uploads
  • Env: FLASK_ENV=production, SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD
  • Healthcheck: HTTP GET http://localhost:5000/ (30s interval, 40s start)
  • Restart: unless-stopped

Service caddy

  • Image: caddy:2-alpine
  • Ports: 8080:80, 8443:443
  • Volumes: ./data/Caddyfile:/etc/caddy/Caddyfile:rw, ./data/caddy-data:/data, ./data/caddy-config:/config, ./data/caddy-logs:/var/log/caddy
  • Depends on: app (service started)
  • Healthcheck: wget on port 80

3. Dockerfile

Stage Content
Base python:3.13-slim
System deps poppler-utils, ffmpeg, libmagic1, sudo, fonts-noto-color-emoji, LibreOffice (core/impress/writer), sshpass, openssh-client, rsync, git
Python COPY requirements.txtpip install (cached layer), then COPY . .
App config FLASK_APP=app.app:create_app, FLASK_ENV=production, EXPOSE 5000
User Non-root appuser (UID 1000) with passwordless sudo limited to apt-get, install_libreoffice.sh, install_emoji_fonts.sh
Runtime HEALTHCHECK (HTTP 5000), ENTRYPOINT /docker-entrypoint.sh

4. docker-entrypoint.sh

  1. Pin the database: export DATABASE_URL (default sqlite:////app/instance/dashboard.db) so migrations and the app resolve to the same file. Required because the config classes default to different files (dev.db vs dashboard.db) and most migration scripts call create_app() without an argument (→ development config), while the app runs create_app('production').
  2. Create /app/instance and /app/app/static/uploads.
  3. Ensure schema + admin user — runs on every start (idempotent): db.create_all(), then create the admin from ADMIN_USERNAME / ADMIN_PASSWORD or refresh its password.
  4. Run the migration chain (idempotent, ordered — table-creating migrations run before those that alter them):
add_https_config_table.py
add_player_user_table.py
add_email_to_https_config.py
migrate_player_user_global.py
add_url_to_content.py
add_original_filename_to_content.py
add_deployment_fields_to_player.py

Migrations are non-fatal: failures are logged as ⚠️ WARNING and startup continues, so one bad migration can't strand the container in a restart loop.

  1. Start Gunicorn: --bind 0.0.0.0:5000 --workers 4 --timeout 120 app.app:create_app('production').

Because migrations now run automatically on startup, deploy.sh step 4 is redundant (harmless — the scripts are idempotent).

Data layout (bind mounts)

Host path Container path Contents
data/instance /app/instance SQLite DB (dashboard.db), player_build.json
data/uploads /app/app/static/uploads Media files + edited_media/
data/Caddyfile /etc/caddy/Caddyfile Reverse-proxy config (a file, not a directory)
data/caddy-data /data Caddy state (instance UUID, certificates)
data/caddy-config /config Caddy autosave
data/caddy-logs /var/log/caddy Access logs

⚠️ data/Caddyfile must exist before docker compose up. It is bind-mounted as a file; if it is missing, Docker creates a directory in its place and Caddy fails to start. deploy.sh seeds it from the version-controlled Caddyfile.example. For a manual start:

mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
cp Caddyfile.example data/Caddyfile
docker compose up -d --build

The legacy data/nginx-* and data/certbot folders are obsolete — the reverse proxy is Caddy. They are no longer created by deploy.sh.

Clean start (wipe all runtime data)

data/ is gitignored, so wiping it is irreversible. To reset to a pristine deployment:

docker compose down
docker rmi digiserver-v2-digiserver-app:latest        # drop stale image
docker image prune -f && docker builder prune -a -f   # reclaim build cache
rm -rf data                                            # WIPES db, uploads, certs
mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
cp Caddyfile.example data/Caddyfile
./deploy.sh

⚠️ Files under data/caddy-* are created root-owned by the Caddy container, so a plain rm -rf data may fail with Permission denied. Remove them via a helper container:

docker run --rm -v "$PWD/data:/data" caddy:2-alpine sh -c 'rm -rf /data/caddy-config /data/caddy-data'

Avoid docker system prune --volumes — this host also holds volumes for other projects.


5. deploy.sh (One-Shot Deployment)

1. Detect compose: `docker compose` (plugin) or `docker-compose` (v1 fallback)
   — stored in $COMPOSE and used for every subsequent call
2. Create data/ subdirs (instance, uploads, caddy-data, caddy-config, caddy-logs)
   and seed data/Caddyfile from Caddyfile.example
3. $COMPOSE up -d + verify containers "Up"
4. Run migration scripts (add_https_config_table, add_player_user_table,
   add_email_to_https_config, migrate_player_user_global,
   add_original_filename_to_content)
   ↳ NOTE: the container entrypoint already applies all seven on startup.
     This step is idempotent and therefore redundant.
5. /app/https_manager.py enable <hostname> <domain> <email> <ip> <port>
   ↳ Exit code 2 ("config applied, Caddy not reloaded") is non-fatal.
6. Verify DB tables via SQLAlchemy inspector; caddy validate;
   https_manager.py status; print access URLs + default creds

Configuration variables

Variable Default Meaning
HOSTNAME digiserver Display hostname
HTTPS_MODE internal internal | acme | off
DOMAIN (empty) Required only when HTTPS_MODE=acme
IP_ADDRESS auto-detected Primary LAN IP (override if needed)
EMAIL admin@example.com ACME account email (unused by internal CA)
PORT 8443 Externally published HTTPS port

If IP_ADDRESS is unset, deploy.sh auto-detects it (ip -4 route get 1.1.1.1src address, falling back to hostname -I). The old hard-coded defaults (10.76.152.164, a .intra domain) were wrong for most hosts and have been removed.


6. HTTPS Setup (Caddy)

Three equivalent entry points drive the same code path (HTTPSConfig + CaddyConfigGenerator) so CLI, env bootstrap and UI cannot diverge:

  1. Env bootstrap (deploy time) — the container entrypoint runs python /app/https_manager.py bootstrap, which reads HOSTNAME_INTERNAL and HOST_IP from the environment and configures Caddy for HTTPS automatically.
  2. CLIpython /app/https_manager.py enable … | verify | status | disable
  3. UIAdmin → HTTPS Configuration (reloads Caddy live; the ongoing source of truth)

Addressing model — one HTTP endpoint, one HTTPS endpoint

Port What it does
80 Always answers. A catch-all :80 block serves any Host header, plus explicit blocks for the IP and hostname so both work.
443 HTTPS for the same names, using the internal CA (or ACME for a public domain).

If HTTPS is disabled or never configured, port 80 simply serves the app — there is no separate "HTTP mode" to set.

⚠️ default_sni is required for IP access. Browsers send no SNI when the URL is an IP address (an IP is not a valid SNI hostname). Caddy then identifies the connection by the container's own internal IP and aborts the handshake with no certificate available for '<container-ip>'. To prevent this, the generator emits default_sni <ip> whenever internal-CA mode is used, so https://<ip> works in a plain browser. This was found by end-to-end testing — see docs/tools/test_http_https_runtime.sh.

Mode selection

Condition Result
HTTPS off, or no IP/domain Plain HTTP on port 80
HTTPS on + ip_address / hostname tls internal per name (no DNS, no ACME)
HTTPS on + domain set Let's Encrypt for that name

Automatic fallback if HTTPS does not work

After applying a config, https_manager.py probes the HTTPS endpoint (/api/health, certificate validation deliberately disabled). If the TLS listener does not come up, the configuration is automatically reverted to plain HTTP so a failed certificate can never make the site unreachable:

enable HTTPS → reload Caddy → probe https://<host>:<port>/api/health
                               ├─ OK   → keep HTTPS
                               └─ FAIL → revert to HTTP-only, log a warning

Disable the probe with HTTPS_VERIFY=false (or enable --no-verify). Re-check at any time with python /app/https_manager.py verify.

Deploy-time bootstrap via .env

Copy .env.example.env and set the host address. docker-compose.yml forwards these to the app container:

Variable Effect
HOSTNAME_INTERNAL Hostname served (both HTTP and HTTPS)
HOST_IP IP served and certified
DOMAIN Leave empty for an intranet name → internal CA. Set only for Let's Encrypt
SSL_EMAIL ACME contact (ignored by internal CA)
HTTP_PORT / HTTPS_PORT Host ports mapped to Caddy's 80/443 (default 80/443)
HTTPS_HTTP_FALLBACK true (default) also serves plain HTTP; false redirects instead
HTTPS_VERIFY true (default) probe HTTPS and auto-fall back on failure
cp .env.example .env
# set HOSTNAME_INTERNAL and HOST_IP
docker compose up -d --build

If either HOSTNAME_INTERNAL or HOST_IP is missing the bootstrap is a no-op — the app starts on plain HTTP and stays reachable. HTTPS can then be enabled from Admin → HTTPS Configuration, which regenerates the Caddyfile and reloads Caddy without a restart.

Who owns the config (env vs admin UI)

The admin UI is the ongoing source of truth. The bootstrap runs on every container start, so it guards against silently overwriting an admin's change by tracking provenance in HTTPSConfig.updated_by:

Current config Bootstrap behaviour
(none — first deploy) Apply from env
Written by env/CLI (updated_by='deploy.sh') Apply from env — so editing HOST_IP and redeploying works
Written by a user (updated_by='<username>') Skip — the admin's setting is preserved

So an admin change made in the UI survives restarts even while the env vars remain set.

Why internal CA (and not Let's Encrypt) for .intra

An intranet name such as digiserver.sibiusb.harting.intra is not resolvable from the public internet, so Let's Encrypt's HTTP-01/TLS-ALPN challenge cannot succeed. Setting DOMAIN= empty makes Caddy sign the certificate itself with its local CA — no external dependency at all.

Trust caveat: the internal CA is not in any client trust store, so browsers show a warning and a Kivy player with verify_ssl: true will reject the connection. Options:

  1. Use HTTP — port 80 always serves the app, so players need no trust configuration.
  2. Install the root CA on each device:
    docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
    

Ports

Host → Container Purpose
80 → 80 HTTP (always available)
443 → 443 HTTPS
5000 → 5000 Direct app access (bypasses Caddy; dev/testing)

Ports are configurable via HTTP_PORT/HTTPS_PORT so the stack also works where 80/443 are already taken (e.g. HTTP_PORT=8080 HTTPS_PORT=8443).


7. verify-deployment.sh — Pre/Post-Deployment Checks

Sections checked (pass/fail/warn counters):

  • git status
  • .env / .env.example
  • Docker + Compose versions (plugin or v1) + compose config syntax
  • Dockerfile best practices (HEALTHCHECK, non-root, slim base)
  • requirements.txt critical packages + versions
  • migrations directory
  • TLS certificate — Caddy internal CA expiry (data/caddy-data/caddy/pki/authorities/local/root.crt)
  • Flask config (ProductionConfig, SESSION_COOKIE_SECURE)
  • data/Caddyfile checks (reverse_proxy, admin API, TLS mode)
  • runtime container health + live HTTP/HTTPS endpoint probes
  • security best practices

The script now detects docker compose (plugin) or docker-compose (v1) and warns when buildx is too old for compose v1 builds — matching deploy.sh, which then falls back to docker build.


8. Player Deployment Pipeline

sequenceDiagram
    participant U as Admin
    participant A as App
    participant B as Background
    participant H as Player host
    U->>A: Add player (name, hostname, password/quickconnect)
    A->>B: background_player_deployment()
    B->>B: build/refresh staged code (PLAYER_CODE_DIR)
    B->>H: sshpass + ssh test
    B->>H: rsync code (or git clone/pull fallback)
    B->>H: write config/app_config.json (server_ip, quickconnect, https...)
    B->>H: temporary passwordless sudo
    B->>H: ./install.sh && ./start.sh
    B->>H: remove temp sudoers
    B-->>A: player.deployment_status = 'deployed' | 'failed'
    U->>A: poll /players/deployment-status

Statuses: pending → deploying → deployed | failed, with last_deployment_at/status/message persisted on the player row. Receiving player feedback also auto-marks deployment deployed.


9. Ports & Volumes Summary

Item Value
App port (container) 5000
App port (host, dev) 5000
Caddy HTTP 8080 → 80
Caddy HTTPS 8443 → 443
Caddy admin API 2019 (container)
DB volume ./data/instance
Uploads volume ./data/uploads
Caddy data/config/logs ./data/caddy-*

Next: 08 · Workflows