# `docker compose up -d` stands up an archive. # # Two containers by default: caddy (the only thing that publishes a port) and # editor (the admin app). The three other apps are behind profiles, so somebody # who only wants an archive runs two containers, not five: # # docker compose up -d editor + caddy # docker compose --profile site up -d + the published archive # docker compose --profile site --profile homepage up -d # docker compose --profile umtool up -d # # THE EXPOSURE MODEL, which is the load-bearing part of this file: # # * No application container publishes a port. They talk to caddy over an # internal network and are unreachable from the host except through it. # * Caddy publishes four ports and EVERY ONE binds 127.0.0.1 by default — # the public sites included. Nothing is reachable off this box until you # change a bind in .env. # * The editor has no authentication of its own. Binding it to anything but # loopback without an auth layer is refused at startup, loudly. See # docker/guard-exposure.sh. # # See RUNNING_IN_DOCKER.md. Copy .env.example to .env before your first run. name: archilyzer # --------------------------------------------------------------------------- # Every app runs the same image; the command selects which one. # --------------------------------------------------------------------------- x-app: &app build: context: . target: runtime # Which commit the image is built from, for the publish stamps (the image # has no .git). Empty unless the shell sets them: # ARCHILYZER_COMMIT=$(git rev-parse HEAD) \ # ARCHILYZER_BRANCH=$(git branch --show-current) docker compose build args: ARCHILYZER_COMMIT: ${ARCHILYZER_COMMIT:-} ARCHILYZER_BRANCH: ${ARCHILYZER_BRANCH:-} image: archilyzer:${ARCHILYZER_TAG:-local} restart: unless-stopped networks: [archilyzer] # env_file rather than `environment:` for the auth variables, and this is not # cosmetic: a bcrypt hash is full of `$`, and compose interpolates `${...}` in # the YAML but passes env_file values through verbatim. Put the hash in .env # and it arrives intact, with nothing to escape. # # The same goes for the publish credentials: CLOUDFLARE_API_TOKEN, # CLOUDFLARE_ACCOUNT_ID and the R2_* keys are read from .env by the editor, # and every publish stage it runs (a child process) inherits them. They are # deliberately NOT in x-app-env below — a value there would override .env. env_file: - path: .env required: false x-app-env: &app-env TRANSCRIPTS_DIR: /data/transcripts SETTINGS_FILE: /data/config/settings.json # NOTHING ABOUT THE TRANSCRIPTION ENGINE BELONGS HERE. # # Which engine, which binary, which model file and which model to fetch are # properties of the IMAGE (each runtime target sets them), and the entrypoint # defaults the download per engine. Naming a whisper model here looks harmless # and quietly breaks the Vulkan image, which wanted a parakeet GGUF and got # sent to fetch "base.en.gguf" — measured, on the first boot of that image. # Override any of them from .env, which every service already reads. # # Build staging and the published site live in a volume, not in the container's # writable layer — a full build's staging is tens of GB. EXPORT_INDEX_DIR: /data/builds/.export-index EXPORT_BUILDS_DIR: /data/builds/.export-builds ARCHILYZER_SITE_OUT: /data/builds/site # `publish homepage --deploy --to local` writes here; the homepage service # serves it once it is non-empty (else the image's baked build). ARCHILYZER_HOMEPAGE_OUT: /data/builds/homepage # The operator's private config dir (common/lib/paths.ts): the source # mirror's scrub rules and denylist live here, in the config volume — # `docker compose cp` them in; they are never printed. ARCHILYZER_CONFIG_DIR: /data/config/archilyzer # Set explicitly so a stray EDITOR_PORT/UMTOOL_PORT in .env cannot move an app # off the port Caddy proxies to. These are INTERNAL ports; the published ones # are on the caddy service below. EDITOR_PORT: "3001" UMTOOL_PORT: "3050" services: # ------------------------------------------------------------------------- # The front door. The only service with `ports:`. # ------------------------------------------------------------------------- caddy: image: caddy:2.11-alpine restart: unless-stopped networks: [archilyzer] env_file: - path: .env required: false # Runs the same exposure rail the app entrypoint does — this is the process # that actually opens the ports, so it refuses first. entrypoint: ["/bin/sh", "/etc/archilyzer/caddy-start.sh"] ports: # published-bind : published-port : caddy-port - "${SITE_BIND:-127.0.0.1}:${SITE_HTTP_PORT:-8080}:8080" - "${EDITOR_BIND:-127.0.0.1}:${EDITOR_HTTP_PORT:-8081}:8081" - "${HOMEPAGE_BIND:-127.0.0.1}:${HOMEPAGE_HTTP_PORT:-8082}:8082" - "${UMTOOL_BIND:-127.0.0.1}:${UMTOOL_HTTP_PORT:-8083}:8083" volumes: - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro - ./docker:/etc/archilyzer:ro - caddy-data:/data - caddy-config:/config # ------------------------------------------------------------------------- # The admin app. Downloads, transcribes, builds, deletes. No login of its own. # ------------------------------------------------------------------------- editor: <<: *app command: ["editor"] environment: <<: *app-env # The publish lock's host identity (common/publish/stageLock.ts). Its # default, the hostname, is the container id here and changes on every # recreate, so a lock left by a crashed stage would look like another # host's forever. Fixed, so this editor recognises its own stale lock. # The EDITOR only, deliberately: another container with the same id but # its own pid namespace would judge the editor's live lock dead. ARCHILYZER_HOST_ID: archilyzer-editor volumes: - corpus:/data/transcripts - config:/data/config - models:/data/models - builds:/data/builds healthcheck: # /api/pulse answers from in-memory state plus two stat() calls — it is # built to be polled forever, which is exactly what a healthcheck does. test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:3001/api/pulse"] interval: 15s timeout: 10s retries: 10 start_period: 40s # Long enough for armShutdownCancel() to reap a running yt-dlp or whisper # child rather than have it killed out from under a half-written file. stop_grace_period: 30s # ------------------------------------------------------------------------- # The published archive: static files, built by docker/publish-site.sh. # ------------------------------------------------------------------------- site: <<: *app profiles: [site] command: ["site"] environment: <<: *app-env volumes: # Writable, and only for one reason: before anything has been published, # the entrypoint drops a placeholder page into the site directory that says # how to publish. Serving that beats serving a 404 nobody can interpret. # Publishing later needs no restart — `serve` reads from disk per request. - builds:/data/builds # ------------------------------------------------------------------------- # The project's own site (marketing + docs). A build is baked into the # image; a local deploy (`publish homepage --deploy --to local`) into the # builds volume replaces it once there is one — restart this service after # the first. # ------------------------------------------------------------------------- homepage: <<: *app profiles: [homepage] command: ["homepage"] environment: <<: *app-env volumes: # Writable for the same reason as the site's: the entrypoint makes the # (empty) directory it looks in. - builds:/data/builds # ------------------------------------------------------------------------- # umtool: the clip/report bench. Private, like the editor. # ------------------------------------------------------------------------- umtool: <<: *app profiles: [umtool] command: ["umtool"] environment: <<: *app-env volumes: - corpus:/data/transcripts - config:/data/config - builds:/data/builds networks: archilyzer: # A normal bridge, deliberately NOT `internal: true` — yt-dlp needs the # internet. What keeps these containers private is that none of them # publishes a port, not that they are cut off from the network. driver: bridge volumes: # The corpus. This is the one that matters: real media, real transcripts, # hundreds of GB when it grows up. Back it up. corpus: # settings.json, the operator's private config dir (archilyzer/: the source # mirror's rules), and anything else operational that must outlive the # container. config: # whisper .bin models — 142 MB to 3 GB, fetched once. models: # Export build staging, the shared index, every site's bundle and stamps, and # the locally deployed site and homepage. Reproducible; losing it costs a # rebuild, not data. builds: caddy-data: caddy-config: