# WireHub — the self-hosted stack, in one file. # # docker compose up -d # then open http://localhost:5183/setup # # No .env is needed: the `bootstrap` service generates every secret on first # run into the `secrets` volume, and the app prints the first-run setup code # in its log (`docker compose logs wirehub`, or the wirehub container's logs # in your Docker UI). An optional .env (see .env.example, or the config # generator: https://formless63.github.io/wirehub/) sets what the install # needs: the public URL, ports, optional parts, your own S3 or Postgres and # backups. How the hub behaves (sign-in methods, alerts, the git mirror, job # settings) is set in the app, under Settings, and applies without a redeploy. # # Default services: # bootstrap one-shot: secrets, the setup code and Garage's config # wirehub the app (UI + API), the only published port # postgres PostgreSQL 18: the catalog, designs, saved versions, # accounts and the history of every save # migrate one-shot: the database roles, every pending migration, and # (once) a file catalog from before the database moved in # worker background jobs: module imports, 3D model conversion and # builds, and housekeeping (blob clean-up, the backup watch) # garage S3-compatible store for uploaded files (photos, datasheets, # 3D models) — Garage, single node # garage-init one-shot: Garage's layout, its access keys and the bucket # # Optional parts are profiles, chosen with COMPOSE_PROFILES in .env: # backup restic backups through Backrest (web UI on port 9898): # backup-init, backup-dump, backup-mirror, backrest # pdf the browser PDF engine (Gotenberg: headless Chromium) that # prints the HTML sheets to PDF exactly as a browser does; # also set WIREHUB_PDF_ENGINE_URL=http://pdf:3000 (or name # it in Settings > Integrations) # # Using your own Postgres or S3: delete the blocks between ">>> bundled-…" and # "<<< bundled-…" and set DATABASE_ADMIN_URL or the S3_* variables (the config # generator does both for you). # # Nothing is published beyond the app's port (and Backrest's, with the backup # profile), which bind to 127.0.0.1 unless WIREHUB_BIND / BACKREST_BIND say # otherwise; put a TLS-terminating reverse proxy in front for anything but # local use. name: wirehub services: bootstrap: image: &wirehub-image ${WIREHUB_IMAGE:-ghcr.io/formless63/wirehub:0.8.0} # x-release-please-version user: "0:0" command: ["node", "--experimental-strip-types", "--no-warnings", "stack/bootstrap.ts"] environment: # explicit values win over generated ones (unset = generate) POSTGRES_PASSWORD: WIREHUB_OWNER_PASSWORD: WIREHUB_APP_PASSWORD: WIREHUB_RO_PASSWORD: # your own Postgres: an admin connection; the role URLs are derived from it DATABASE_ADMIN_URL: DATABASE_OWNER_URL: DATABASE_URL: DATABASE_RO_URL: BETTER_AUTH_SECRET: WIREHUB_SETTINGS_KEY: GARAGE_RPC_SECRET: GARAGE_ADMIN_TOKEN: WIREHUB_SETUP_CODE: S3_REGION: ${S3_REGION:-garage} volumes: - secrets:/run/wirehub network_mode: none mem_limit: 128m restart: "no" wirehub: image: *wirehub-image environment: BETTER_AUTH_URL: ${WIREHUB_PUBLIC_URL:-http://localhost:${WIREHUB_PORT:-5183}} WIREHUB_BACKEND: ${WIREHUB_BACKEND:-pg} WIREHUB_ENV: ${WIREHUB_ENV:-prod} WIREHUB_ORG: WIREHUB_TRUST_PROXY: WIREHUB_PROD_MARKERS: WIREHUB_ALLOW_FILES_IN_PROD: WIREHUB_SUGGESTED_MODULES: WIREHUB_STORE_INDEXES: # the browser PDF engine (profile `pdf`): http://pdf:3000; unset = the headless PDFs # (or name it in Settings > Integrations) WIREHUB_PDF_ENGINE_URL: # the file Backrest's post-snapshot hook touches (profile `backup`); the deep health check reads it WIREHUB_BACKUP_MARKER: ${WIREHUB_BACKUP_MARKER:-/backup-marker/.last-snapshot} # sign-in: on, with email + password accounts; first-run setup makes the admin. The # methods (OIDC, magic link), allowed emails, alerts, the git mirror and the job # settings are set in the app (Settings), not here (docs/self-hosting.md, "What lives where") AUTH_ENABLED: ${AUTH_ENABLED:-true} AUTH_DATA_DIR: /data/auth WIREHUB_MODEL_CACHE_DIR: /data/cache/models WIREHUB_PACKS_DIR: /data/packs WIREHUB_BLOBS: ${WIREHUB_BLOBS:-s3} S3_ENDPOINT: ${S3_ENDPOINT:-http://garage:3900} S3_REGION: ${S3_REGION:-garage} S3_BUCKET: ${S3_BUCKET:-wirehub} # secrets: the generated files, unless set explicitly DATABASE_URL: DATABASE_URL_FILE: /run/wirehub/database_url BETTER_AUTH_SECRET: BETTER_AUTH_SECRET_FILE: /run/wirehub/better_auth_secret # encrypts the secrets entered in Settings (SMTP password, OIDC client secret, webhook) WIREHUB_SETTINGS_KEY: WIREHUB_SETTINGS_KEY_FILE: /run/wirehub/settings_key # while rotating the key: the old one(s), comma separated (the volume's settings_key_previous is read too) WIREHUB_SETTINGS_KEY_PREVIOUS: WIREHUB_SETUP_CODE: WIREHUB_SETUP_CODE_FILE: /run/wirehub/setup_code S3_ACCESS_KEY_ID: S3_ACCESS_KEY_ID_FILE: /run/wirehub/s3_access_key_id S3_SECRET_ACCESS_KEY: S3_SECRET_ACCESS_KEY_FILE: /run/wirehub/s3_secret_access_key WIREHUB_LOCAL_USER: # jobs run in the worker service; off = in the app (then drop the worker service) WIREHUB_WORKER: # code modules an owner installs at runtime (a store or a signed upload): false turns them all off WIREHUB_ALLOW_CODE_MODULES: # Settings > Restart WireHub exits the app; restart: unless-stopped (below) starts it again WIREHUB_RESTART_SUPERVISED: "true" TZ: ${TZ:-UTC} ports: - "${WIREHUB_BIND:-127.0.0.1}:${WIREHUB_PORT:-5183}:5183" volumes: - catalog:/app/packages/catalog/data - auth:/data/auth - packs:/data/packs - blobs:/data/blobs - cache:/data/cache - secrets:/run/wirehub:ro - backup_marker:/backup-marker:ro depends_on: bootstrap: condition: service_completed_successfully migrate: condition: service_completed_successfully # >>> bundled-s3 garage-init: condition: service_completed_successfully # <<< bundled-s3 mem_limit: 768m restart: unless-stopped networks: [internal, public] migrate: image: *wirehub-image # *_FILE variables resolved first (server/boot-env.ts), then the roles, the # migrations, and — once, on a database with no organisation — a file # catalog from before the database moved in (a fresh install is left to # first-run setup) command: - sh - -c - >- node --experimental-strip-types --no-warnings --import ./server/boot-env.ts server/pg/cli.ts bootstrap && node --experimental-strip-types --no-warnings --import ./server/boot-env.ts server/pg/cli.ts migrate && node --experimental-strip-types --no-warnings --import ./server/boot-env.ts server/pg/cli.ts adopt --from /app/packages/catalog --packs /data/packs --starter /app/starter-catalog environment: DATABASE_ADMIN_URL_FILE: /run/wirehub/database_admin_url DATABASE_OWNER_URL_FILE: /run/wirehub/database_owner_url DATABASE_URL_FILE: /run/wirehub/database_url WIREHUB_OWNER_PASSWORD_FILE: /run/wirehub/wirehub_owner_password WIREHUB_APP_PASSWORD_FILE: /run/wirehub/wirehub_app_password WIREHUB_RO_PASSWORD_FILE: /run/wirehub/wirehub_ro_password # a file catalog's uploads, moved with it WIREHUB_BLOBS: ${WIREHUB_BLOBS:-s3} S3_ENDPOINT: ${S3_ENDPOINT:-http://garage:3900} S3_REGION: ${S3_REGION:-garage} S3_BUCKET: ${S3_BUCKET:-wirehub} S3_ACCESS_KEY_ID: S3_ACCESS_KEY_ID_FILE: /run/wirehub/s3_access_key_id S3_SECRET_ACCESS_KEY: S3_SECRET_ACCESS_KEY_FILE: /run/wirehub/s3_secret_access_key volumes: - secrets:/run/wirehub:ro - catalog:/app/packages/catalog/data - packs:/data/packs - blobs:/data/blobs depends_on: bootstrap: condition: service_completed_successfully # >>> bundled-postgres postgres: condition: service_healthy # <<< bundled-postgres # >>> bundled-s3 garage-init: condition: service_completed_successfully # <<< bundled-s3 mem_limit: 256m restart: "no" networks: [internal, public] worker: image: *wirehub-image command: ["node", "--experimental-strip-types", "--no-warnings", "server/worker.ts"] environment: WIREHUB_BACKEND: ${WIREHUB_BACKEND:-pg} WIREHUB_ENV: ${WIREHUB_ENV:-prod} WIREHUB_ORG: WIREHUB_PROD_MARKERS: WIREHUB_ALLOW_FILES_IN_PROD: WIREHUB_BLOBS: ${WIREHUB_BLOBS:-s3} S3_ENDPOINT: ${S3_ENDPOINT:-http://garage:3900} S3_REGION: ${S3_REGION:-garage} S3_BUCKET: ${S3_BUCKET:-wirehub} DATABASE_URL: DATABASE_URL_FILE: /run/wirehub/database_url S3_ACCESS_KEY_ID: S3_ACCESS_KEY_ID_FILE: /run/wirehub/s3_access_key_id S3_SECRET_ACCESS_KEY: S3_SECRET_ACCESS_KEY_FILE: /run/wirehub/s3_secret_access_key # reads the secrets entered in Settings (the webhook, the git mirror's credentials) WIREHUB_SETTINGS_KEY: WIREHUB_SETTINGS_KEY_FILE: /run/wirehub/settings_key # while rotating the key: the old one(s), comma separated (the volume's settings_key_previous is read too) WIREHUB_SETTINGS_KEY_PREVIOUS: # the backup profile's volume, read-only: GC deletes an orphan only after a later backup WIREHUB_BACKUP_DIR: /backups # one STEP conversion at a time, stopped past this size (MB) to stay inside mem_limit WIREHUB_STEP_RSS_LIMIT_MB: ${WIREHUB_STEP_RSS_LIMIT_MB:-1280} WIREHUB_BACKUP_MARKER: ${WIREHUB_BACKUP_MARKER:-/backup-marker/.last-snapshot} # optional: a folder of model source files (mount it here) for imported models WIREHUB_MODEL_SOURCES: # optional: the git mirror into a repository mounted into the worker; a remote # (and its key or token), the build window and alerts are set in Settings WIREHUB_GIT_MIRROR_PATH: # the same runtime code modules as the app (and the same switch) WIREHUB_ALLOW_CODE_MODULES: TZ: ${TZ:-UTC} volumes: - secrets:/run/wirehub:ro # WIREHUB_BLOBS=fs:/data/blobs keeps uploaded files here, shared with the app - blobs:/data/blobs - backups:/backups:ro - backup_marker:/backup-marker:ro depends_on: bootstrap: condition: service_completed_successfully migrate: condition: service_completed_successfully # >>> bundled-s3 garage-init: condition: service_completed_successfully # <<< bundled-s3 healthcheck: # the worker touches this file with every heartbeat (each minute) test: ["CMD-SHELL", "test -n \"$$(find /tmp/wirehub-worker.beat -mmin -3 2>/dev/null)\""] interval: 60s timeout: 5s start_period: 60s mem_limit: 1536m restart: unless-stopped networks: [internal, public] # >>> bundled-postgres: the bundled PostgreSQL 18. Using your own? Delete down to "<<< bundled-postgres" and set DATABASE_ADMIN_URL. postgres: image: postgres:18.6-bookworm environment: POSTGRES_USER: wirehub POSTGRES_DB: wirehub POSTGRES_PASSWORD_FILE: /run/wirehub/postgres_password volumes: - pg_data:/var/lib/postgresql - secrets:/run/wirehub:ro depends_on: bootstrap: condition: service_completed_successfully healthcheck: test: ["CMD-SHELL", "pg_isready -U wirehub -d wirehub"] interval: 10s timeout: 5s retries: 6 mem_limit: 512m restart: unless-stopped networks: [internal] # <<< bundled-postgres # >>> bundled-s3: Garage, the bundled S3 store. Using your own S3? Delete down to "<<< bundled-s3" and set the S3_* variables. garage: image: dxflrs/garage:v2.4.1 command: ["/garage", "-c", "/run/wirehub/garage.toml", "server"] volumes: - garage_meta:/var/lib/garage/meta - garage_data:/var/lib/garage/data - secrets:/run/wirehub:ro depends_on: bootstrap: condition: service_completed_successfully mem_limit: 256m restart: unless-stopped networks: [internal] garage-init: image: *wirehub-image command: ["node", "--experimental-strip-types", "--no-warnings", "stack/garage-init.ts"] environment: GARAGE_ADMIN_URL: http://garage:3903 GARAGE_CAPACITY: ${GARAGE_CAPACITY:-100G} S3_BUCKET: ${S3_BUCKET:-wirehub} # explicit keys are imported and win; otherwise Garage creates them S3_ACCESS_KEY_ID: S3_SECRET_ACCESS_KEY: S3_BACKUP_ACCESS_KEY_ID: S3_BACKUP_SECRET_ACCESS_KEY: volumes: - secrets:/run/wirehub depends_on: bootstrap: condition: service_completed_successfully garage: condition: service_started mem_limit: 128m restart: "no" networks: [internal] # <<< bundled-s3 # --- profile `backup`: restic through Backrest ---------------------------- # backup-dump and backup-mirror stage the database dump and a mirror of the # bucket into the `backups` volume; Backrest snapshots it with the catalog, # auth and packs volumes to BACKUP_REPOSITORY (default: a local repository # in the `restic_repo` volume — set a repository on another machine). backup-init: profiles: [backup] image: *wirehub-image user: "0:0" command: ["node", "--experimental-strip-types", "--no-warnings", "stack/backup-init.ts"] environment: BACKREST_CONFIG: /config/config.json BACKUP_REPOSITORY: BACKUP_REPOSITORY_PASSWORD: BACKUP_AWS_ACCESS_KEY_ID: BACKUP_AWS_SECRET_ACCESS_KEY: BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 3 * * *} volumes: - secrets:/run/wirehub - backrest_config:/config - backup_marker:/marker depends_on: bootstrap: condition: service_completed_successfully network_mode: none mem_limit: 128m restart: "no" backup-dump: profiles: [backup] image: postgres:18.6-bookworm entrypoint: ["bash", "/run/wirehub/backup/pg-dump.sh"] environment: # the read-only role: it reads every table (BYPASSRLS) and can change nothing DATABASE_URL_FILE: /run/wirehub/database_ro_url # the weekly restore check restores into a scratch database: it needs a role that may create one ADMIN_URL_FILE: /run/wirehub/database_admin_url BACKUP_DUMP_AT: ${BACKUP_DUMP_AT:-02:30} BACKUP_KEEP_DUMPS: ${BACKUP_KEEP_DUMPS:-7} BACKUP_CHECK_DAY: ${BACKUP_CHECK_DAY:-0} TZ: ${TZ:-UTC} volumes: - secrets:/run/wirehub:ro - backups:/backups depends_on: backup-init: condition: service_completed_successfully # the roles (studio_ro) and the schema exist once migrate has run migrate: condition: service_completed_successfully mem_limit: 256m restart: unless-stopped networks: [internal, public] backup-mirror: profiles: [backup] image: rclone/rclone:1.75.1 entrypoint: ["sh", "/run/wirehub/backup/blob-mirror.sh"] environment: S3_ENDPOINT: ${S3_ENDPOINT:-http://garage:3900} S3_REGION: ${S3_REGION:-garage} S3_BUCKET: ${S3_BUCKET:-wirehub} BACKUP_MIRROR_INTERVAL: ${BACKUP_MIRROR_INTERVAL:-3600} S3_BACKUP_ACCESS_KEY_ID: S3_BACKUP_ACCESS_KEY_ID_FILE: /run/wirehub/s3_backup_access_key_id S3_BACKUP_SECRET_ACCESS_KEY: S3_BACKUP_SECRET_ACCESS_KEY_FILE: /run/wirehub/s3_backup_secret_access_key S3_ACCESS_KEY_ID: S3_ACCESS_KEY_ID_FILE: /run/wirehub/s3_access_key_id S3_SECRET_ACCESS_KEY: S3_SECRET_ACCESS_KEY_FILE: /run/wirehub/s3_secret_access_key volumes: - secrets:/run/wirehub:ro - backups:/backups depends_on: backup-init: condition: service_completed_successfully # >>> bundled-s3 garage-init: condition: service_completed_successfully # <<< bundled-s3 mem_limit: 256m restart: unless-stopped networks: [internal, public] backrest: profiles: [backup] image: garethgeorge/backrest:v1.14.1 environment: BACKREST_DATA: /data BACKREST_CONFIG: /config/config.json XDG_CACHE_HOME: /cache TMPDIR: /tmp TZ: ${TZ:-UTC} volumes: - backrest_data:/data - backrest_config:/config - backrest_cache:/cache - restic_repo:/repos - backups:/sources/backups:ro - catalog:/sources/catalog:ro - auth:/sources/auth:ro - packs:/sources/packs:ro # the post-snapshot hook touches /marker/.last-snapshot; the app and the worker read it, read-only - backup_marker:/marker ports: - "${BACKREST_BIND:-127.0.0.1}:${BACKREST_PORT:-9898}:9898" depends_on: backup-init: condition: service_completed_successfully mem_limit: 512m restart: unless-stopped # internal: the staging services; public: a repository on another machine networks: [internal, public] # --- profile `pdf`: the browser PDF engine ------------------------------- # Gotenberg (Apache-2.0), an HTTP API around headless Chromium: the app posts # a self-contained sheet and gets its PDF back (docs/exports.md). Reachable # only from the stack; JavaScript off and every outbound address refused, # so a sheet can fetch nothing. Point the app at it with # WIREHUB_PDF_ENGINE_URL=http://pdf:3000. pdf: profiles: [pdf] image: gotenberg/gotenberg:8.37.0-chromium command: - gotenberg - --api-timeout=30s - --api-disable-download-from - --webhook-disable - --prometheus-disable-collect - --chromium-disable-javascript - --chromium-deny-public-ips - --chromium-deny-private-ips - --chromium-max-concurrency=2 - --chromium-restart-after=50 - --log-level=warn healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"] interval: 30s timeout: 5s start_period: 20s mem_limit: 1g restart: unless-stopped networks: [internal] networks: # postgres and garage are reachable only from the stack, never published internal: internal: true public: {} volumes: secrets: catalog: auth: packs: blobs: cache: # >>> bundled-postgres pg_data: # <<< bundled-postgres # >>> bundled-s3 garage_meta: garage_data: # <<< bundled-s3 backups: backup_marker: backrest_data: backrest_config: backrest_cache: restic_repo: