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.
15 KiB
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; alsoexpose: 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:
wgeton 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.txt → pip 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
- Pin the database: export
DATABASE_URL(defaultsqlite:////app/instance/dashboard.db) so migrations and the app resolve to the same file. Required because the config classes default to different files (dev.dbvsdashboard.db) and most migration scripts callcreate_app()without an argument (→ development config), while the app runscreate_app('production'). - Create
/app/instanceand/app/app/static/uploads. - Ensure schema + admin user — runs on every start (idempotent):
db.create_all(), then create the admin fromADMIN_USERNAME/ADMIN_PASSWORDor refresh its password. - 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.
- 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.shstep 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/Caddyfilemust exist beforedocker 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.shseeds it from the version-controlledCaddyfile.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-*anddata/certbotfolders are obsolete — the reverse proxy is Caddy. They are no longer created bydeploy.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 plainrm -rf datamay 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_ADDRESSis unset,deploy.shauto-detects it (ip -4 route get 1.1.1.1→srcaddress, falling back tohostname -I). The old hard-coded defaults (10.76.152.164, a.intradomain) 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:
- Env bootstrap (deploy time) — the container entrypoint runs
python /app/https_manager.py bootstrap, which readsHOSTNAME_INTERNALandHOST_IPfrom the environment and configures Caddy for HTTPS automatically. - CLI —
python /app/https_manager.py enable … | verify | status | disable - UI — Admin → 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_sniis 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 withno certificate available for '<container-ip>'. To prevent this, the generator emitsdefault_sni <ip>whenever internal-CA mode is used, sohttps://<ip>works in a plain browser. This was found by end-to-end testing — seedocs/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_INTERNALorHOST_IPis 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:
- Use HTTP — port 80 always serves the app, so players need no trust configuration.
- 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_PORTso 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 configsyntax - Dockerfile best practices (HEALTHCHECK, non-root, slim base)
requirements.txtcritical 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/Caddyfilechecks (reverse_proxy, admin API, TLS mode)- runtime container health + live HTTP/HTTPS endpoint probes
- security best practices
The script now detects
docker compose(plugin) ordocker-compose(v1) and warns when buildx is too old for compose v1 builds — matchingdeploy.sh, which then falls back todocker 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