# Container, staging, and production deployment SHD.build runs in a Node 24 container through the official SvelteKit Node adapter. The image includes application code, migration and notification scripts, the Infisical CLI, and native runtime dependencies. Databases, backups, user uploads, and Universal Auth bootstrap credentials remain outside the image. ## Persistent data The staging compose project uses the named volume `shd-build-stage-data`, mounted at `/app/data`. It contains: - `shd-build-staging.db` and its embedded-database support files; - timestamped JSON backups under `backups/`; - normalized user avatars under `avatars/`; - uploaded game-data icons under `game-icons/`. Keep this volume during routine deployments. `docker compose down` without `--volumes` preserves it. Before a manual backup or restore, stop the `app` and `scheduler` services because both access the embedded database. ## Staging environment Staging defaults to: - Public origin: `https://stage.shd.build` - Reverse-proxy upstream: host port `5569` - Database: `/app/data/shd-build-staging.db` - Passkey relying-party ID: `shd.build` - Adapter canonical origin: `https://stage.shd.build` Guide ratings and reports use Cloudflare Turnstile after repeated anonymous activity. Configure both values under `/app` in each Infisical environment: ```text TURNSTILE_SITE_KEY= TURNSTILE_SECRET_KEY= ``` Register the exact staging and production hostnames in the Turnstile widget. The browser receives only `TURNSTILE_SITE_KEY`. The server sends a challenge token and the request address to Cloudflare for verification; it never exposes `TURNSTILE_SECRET_KEY` or stores the raw address as visitor identity. Without a valid secret, a visitor who reaches the challenge threshold cannot submit more anonymous ratings or Guide reports. Edit staging configuration in Infisical project `2ee94b27-e599-4194-9797-3372d42e53f6`: ```text Environment: staging Application path: /app Scheduler path: /shared/scheduler ``` Add `https://stage.shd.build/auth/discord/callback` to the Discord application's allowed OAuth redirect URIs. Point the staging DNS record at the deployment host and configure the host's TLS reverse proxy to forward `stage.shd.build` to port `5569` on the staging host. Docker publishes this port on the host interfaces so a containerized reverse proxy or tunnel can reach it; firewall the port to trusted LAN/tunnel sources when the host is reachable from other networks. The trusted staging runner writes separate mode-`0600` Universal Auth client-ID and client-secret files for the app and scheduler. Compose mounts each pair only into its assigned service. Each container exchanges those credentials for a short-lived access token and writes the assigned paths to mode-`0600` temporary JSON files. The entrypoint rejects unknown secret names, deletes the token, transfers the exports to the dedicated `shd-runtime` UID 10001, and drops root before parsing them. The launcher deletes the files before validation, migration, or startup. Startup fails if the mounted credential files are readable by UID 10001 or by their group or other users. Runtime-created data retains group access for rollback to the previous container UID. | Machine identity | Environment | Injected paths | Gitea variable | Gitea secret | | -------------------------------- | ----------- | --------------------------- | ------------------------------------------ | ---------------------------------------------- | | `shd-build-staging-app` | `staging` | `/app`, `/shared/scheduler` | `INFISICAL_STAGING_APP_CLIENT_ID` | `INFISICAL_STAGING_APP_CLIENT_SECRET` | | `shd-build-staging-scheduler` | `staging` | `/shared/scheduler` | `INFISICAL_STAGING_SCHEDULER_CLIENT_ID` | `INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET` | | `shd-build-production-app` | `prod` | `/app`, `/shared/scheduler` | `INFISICAL_PRODUCTION_APP_CLIENT_ID` | `INFISICAL_PRODUCTION_APP_CLIENT_SECRET` | | `shd-build-production-scheduler` | `prod` | `/shared/scheduler` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET` | The self-hosted Free plan allows five machine identities and does not include Custom Project Roles. The four runtime identities use the built-in Viewer role, which can read the complete project. Service-specific paths limit injected values, but they do not restrict what a stolen root credential could request. Use an Enterprise license with Custom Project Roles, or split runtime scopes into separate projects, before treating identity authorization as least privilege. An Infisical outage does not stop a running container. A new container or direct restart fails closed until `https://secrets.jb.codes` becomes available and the identity can authenticate. Keep the old `.env.*.runtime` files and `DOTENV_PRIVATE_KEY_*` Gitea secrets only through the first successful staging and production cutovers. The rollback path may need them to start the previous image. After both environments pass direct restart checks, rotate every migrated provider credential in Infisical, remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea, and delete local and hosted dotenv key material. Removing encrypted files from the current Git tree does not revoke values stored in Git history. ## Authentication bootstrap and MFA secrets Set the required app-only `MFA_TOTP_ENCRYPTION_KEYS` secret under `/app` in the `dev`, `staging`, and `prod` Infisical environments before starting each app. The value contains one to three comma-separated, unique `key-id:canonical-base64url-32-byte-key` entries. Each key is a random 32-byte value encoded as canonical Base64URL. The first entry is the active write key; following entries are retained decryption keys for older TOTP factors. Do not put this secret under `/shared/scheduler`, in a Gitea variable, in the image, or in a deployment log. A missing, malformed, duplicate, or overlong keyring fails app runtime validation. Rotate the keyring without losing access to existing factors: 1. Generate a new random 32-byte key in the approved secret-management process and assign it a new key ID. Do not copy the value into a document or shell history. 2. Prepend the new `key-id` entry and retain the old entries in Infisical. 3. Deploy or restart the app, then verify an existing TOTP sign-in and a new enrollment. A successful verification rewraps that factor under the first key. 4. Retain each old key until every factor encrypted with it has been rewrapped and verified. Never remove old entries merely because new enrollments use the new key; losing a required old key prevents decryption of those factors. TOTP enrollment issues ten recovery codes and displays them once. The database stores only their domain-separated hashes. The account owner must save the codes in an offline password manager or another protected offline record; do not put their values in Infisical, logs, tickets, backups, audit snapshots, or this documentation. Each code works once. Regenerating codes immediately revokes all unused previous codes and displays a replacement set once. Disabling TOTP also revokes its recovery codes. Sign-in never grants administrator authority automatically. Before offline elevation, the target administrator must have a verified email, an active TOTP factor, and at least one unused recovery code. The target enrolls TOTP at `/account/security` while the app is running. Then stop every app and scheduler service that can access the database and run the environment-specific command. The command requires that no active full administrator already exists, assigns the canonical administrator role, revokes the target's sessions, and records `role_management / administrator.bootstrapped`. For local development: ```sh npm run admin:bootstrap -- ``` For staging, from `/opt/shd-build-stage`: ```sh docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \ app node scripts/admin-bootstrap.mjs docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler ``` For production, from `/opt/shd-build-production`: ```sh docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \ app node scripts/admin-bootstrap.mjs docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler ``` The script requires exactly one active, unblocked account with a verified email, an active TOTP factor, and an unused recovery code. It exits without changing roles when an active full administrator already exists. The Compose commands restart their services; restart the local development server after its command. The target must sign in again because its old sessions were revoked. The staging container disables default-user seeding. A first sign-in never receives Admin; use the offline flow above after a new database or an initial data bootstrap. ## Gitea Actions runner Gitea Actions requires an `act_runner`. Register a trusted runner on the staging host from the repository's **Settings → Actions → Runners** page. The host needs Node/npm, Docker Engine, and Docker Compose v2. The runner account must be able to use Docker and write to the deployment directory. Use runner labels that provide an isolated validation job and a host deployment job: ```text ubuntu-latest:docker://node:24-bookworm,stage-deploy:host ``` The `stage-deploy:host` label is intentional: the deployment step needs the staging host's Docker daemon and persistent named volume. Only attach this trusted runner to repositories allowed to deploy to that host. Gitea warns that Docker daemon access is effectively host-level authority. Prepare the default deployment directory: ```sh sudo install -d -o act_runner -g act_runner -m 0750 /opt/shd-build-stage sudo usermod -aG docker act_runner ``` Restart the runner session after adding it to the Docker group. ## Repository Actions configuration In `https://git.jb.codes/jb/shd.build/settings/actions`: 1. Add repository secrets `INFISICAL_STAGING_APP_CLIENT_SECRET` and `INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET`. 2. Add repository variables `INFISICAL_STAGING_APP_CLIENT_ID` and `INFISICAL_STAGING_SCHEDULER_CLIENT_ID`. 3. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to `/opt/shd-build-stage`. 4. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`. 5. Ensure Actions are enabled and the `stage-deploy` runner is online. `.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual dispatches. The trusted staging runner builds each image locally with a tag that contains the commit, workflow run, and attempt, so the workflow needs no registry credentials and a retry cannot overwrite the active image. Gitea assigns `gitea.run_number` to `SHD_BUILD_NUMBER`; each new workflow run receives the next integer, while retries of the same run retain their number. Staging and production deployments, ISAC exports, and production-to-staging synchronization share the `shd-build-host-maintenance` concurrency group. Gitea runs only one of these database maintenance workflows on the deployment host at a time and never cancels one in progress. ## Deployment lifecycle The workflow stages Compose, credentials, and release metadata in an isolated candidate directory. It leaves the canonical deployment files unchanged until the candidate passes preflight, backup, and migration. The workflow then: 1. installs dependencies, runs `npm run check` and `npm test`, and produces the Node build; 2. builds the candidate container while the current site remains online; 3. starts one-off app and scheduler containers to verify Infisical access and validate the candidate environment; 4. stops only the staging application and notification scheduler; 5. creates a timestamped JSON backup if the staging database exists; 6. applies additive, repeatable migrations in a one-off container; 7. promotes the candidate files to the canonical deployment paths; 8. starts the application and scheduler with the new image; 9. validates the app environment and reruns the repeatable migration in the app entrypoint before serving; 10. verifies `/api/health` and scheduler liveness; 11. restores the previous canonical files and verifies the previous app and scheduler after a failed stop, backup, migration, startup, or health check. On a failed health check, the deploy script prints the final 150 application log lines and exits unsuccessfully. It does not create a separate log artifact. The outage covers backup, migration, and container startup. Migrations may also remove records that use retired data formats when a release explicitly ends compatibility. The deployment backup remains the recovery source for those records; the application does not repair or reinterpret them at runtime. The scheduler calls a bearer-authenticated endpoint on the application service once per minute. Scheduled message delivery therefore uses the web process's existing embedded-database connection instead of opening the locked database from a second process. The endpoint runs automated system messages, Oversight consolidation, email digest checks, and the Ubisoft News importer. Docker does not publish the endpoint through a host port. When enabled in production, the Ubisoft importer keeps its own persisted due time, so the one-minute tick starts a feed synchronization no more than once per hour after a successful run. When a synchronization fails, the importer stores the last error and consecutive failure count, then retries after 15 minutes with exponential backoff capped at six hours. Source items that disappear from the feed remain published; the importer performs no automatic removal. The scheduler runs notification delivery before it starts the bounded Ubisoft request, so a slow provider does not postpone notifications for the current tick. The runtime launcher sets `UBISOFT_NEWS_IMPORT_ENABLED=true` for production and `false` for staging. Development does not set the flag, so the importer remains disabled because it requires the exact value `true`. The application process evaluates the flag when it handles `/api/internal/scheduler`. ## Prelaunch operation The launch-control migration creates `site_launch_settings` with these defaults for a database that does not contain the row: - Coming Soon: enabled - Account creation: enabled - News, Database, Builds, Guides, and Builder: enabled The migration uses an insert-if-missing seed. Later deployments preserve the operator's settings, and configuration changes take effect without a restart. A full environment bootstrap copies the source row. Canonical ISAC game-data imports leave the target environment's launch settings unchanged. Use this operator flow after the first deployment: 1. Open `/sign-in`; the launch gate keeps that route available. 2. Sign in with an existing Administrator account. 3. Open ISAC → System Configuration → Site Controls. 4. Set account creation and public-section availability. 5. Review the complete change set, then confirm it. 6. Use `PREVIEW PAGE` to inspect `/coming-soon` with the authenticated `ENTER FULL SITE` return action. The preview lists each section's current availability. 7. Verify `/coming-soon` and the affected public routes in a signed-out browser session before public release. Sign in and verify the same disabled routes remain unavailable. Keep Coming Soon enabled while preparing production. Section controls can be set in advance because the launch gate takes precedence for signed-out traffic. Set Account Creation to Disabled if prelaunch access must remain limited to existing identities. Switch the Launch Gate to Public Launch after verification. Disabled sections remain absent from navigation and unavailable by direct URL for every user, including authenticated agents. Authentication bypasses only the Coming Soon gate. Turn Coming Soon on to restore the promotional gate. Site-control changes append `system_configuration / site_access.updated` audit events, including the operator and complete before/after settings. ## Initial development-data bootstrap Initialize the first staging release from the development database, including users, roles, builds, guides, proposals, audit history, avatars, and uploaded game-data icons. The staging importer refuses a target database that contains users, builds, audits, or proposals. It also requires the source and target table sets to match exactly; snapshots with retired or unknown tables are rejected. First stop this project's development server. The embedded database must not be open while its complete transfer bundle is created: ```sh npm run stage:bootstrap:export ``` The command writes a timestamped, ignored JSON bundle under `data/backups/`. The bundle includes user records and password hashes. Transfer it over an encrypted channel, keep it out of Git, then copy it to the staging host and run: ```sh /opt/shd-build-stage/bootstrap-staging.sh \ /secure/path/shd-stage-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json ``` The bootstrap flow requires both services to stop, copies the bundle into the private data volume, assigns it to the runtime UID, creates a safety backup, verifies that staging is unused, imports the entire development database and uploads, deletes the volume copy, and restarts both services. Delete the host transfer copy after verifying the imported record counts and critical entities. The copy under the local ignored backup directory remains the recovery source. Because this is a full initial clone, development session tokens and account data also enter staging. Staging and development remain independent from that point forward. ## Pulling canonical ISAC data into development Use the manually triggered **Export staging ISAC data** workflow in Gitea Actions. It briefly stops staging database writers, exports only canonical builder-source tables plus uploaded game-data icons, restarts staging, and publishes a `shd-stage-isac-data` artifact for 14 days. Download `isac-stage.json` from that workflow run, stop this project's development server, and run: ```sh npm run isac:import -- /path/to/isac-stage.json --confirm-replace-isac-data ``` The importer automatically creates both a complete development database backup and an ISAC-only snapshot before replacing canonical data. It does **not** replace local users, roles, sessions, builds, guides, comments, notifications, proposals, contributor agreements, audit history, or system-message settings. Restart development only after the command reports both backup paths and the imported row/icon counts. For a local read-only snapshot without importing: ```sh npm run isac:export ``` ## Manual staging operations From the deployment directory: ```sh docker compose --env-file .env.deploy -f compose.staging.yml ps docker compose --env-file .env.deploy -f compose.staging.yml logs -f app docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps app node scripts/db-backup.mjs docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler ``` Do not use `docker compose down --volumes` for staging. That deletes the named volume containing the embedded database and uploads. ## Production promotion Production uses a separate compose project, volume, database filename, runtime environment, port, and deployment workflow: - Public origin: `https://shd.build` - Reverse-proxy upstream: host port `3102` - Deployment directory: `/opt/shd-build-production` - Database: `/app/data/shd-build-production.db` - Persistent volume: `shd-build-production-data` Routine pushes preserve production data unless an included migration removes a retired prototype format. The production deploy stops its application and scheduler, creates a timestamped JSON backup, applies repeatable migrations, restarts, and verifies database health. Keep `docker compose down --volumes` away from production. Add these repository Action secrets: - `INFISICAL_PRODUCTION_APP_CLIENT_SECRET` - `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET` Add the matching repository variables: - `INFISICAL_PRODUCTION_APP_CLIENT_ID` - `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID` Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default `/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The `deploy-production.yml` workflow deploys production on pushes to `main` and manual dispatches using the trusted `stage-deploy` host runner. Its Gitea workflow run number supplies the production `SHD_BUILD_NUMBER`. Prepare the host directory once from an interactive SSH session: ```sh sudo install -d -o act_runner -g act_runner -m 0750 /opt/shd-build-production ``` Store a separate production `SCHEDULER_TOKEN` under Infisical path `/shared/scheduler`. The application and scheduler fetch the same value from that path. Do not reuse the staging token. ### Initial production bootstrap The production bootstrap clones current development data and uploads. Stop this project's development server before creating the bundle: ```sh npm run production:bootstrap:export ``` Copy the resulting ignored JSON bundle to the deployment host over SSH, then: ```sh /opt/shd-build-production/bootstrap-production.sh \ /secure/path/shd-production-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json ``` The bootstrap script requires both services to stop, stages the bundle in the private data volume with runtime-only access, and removes that copy after the import. The importer verifies the bundle target, creates a production safety backup, and refuses to replace a production database containing users, builds, audit events, or proposals. Remove the remote host copy after verification. If the imported database has no active full administrator, complete the offline administrator flow with the production Compose command in [Authentication bootstrap and MFA secrets](#authentication-bootstrap-and-mfa-secrets) before reopening the site. Do not rely on the first production sign-in for elevation. ### Promote one News article Export a locally authored News article by ID or slug while the development server is stopped: ```sh npm run article:export -- ``` The ignored bundle contains the article draft, immutable revisions, search row, managed-image metadata, and image bytes. It excludes comments, reports, user records, Builds, and ISAC data. After deploying the matching application schema, stop the production app and scheduler. Copy the transferred bundle into `/app/data` with `docker cp`, assign it to `shd-runtime:node` with mode `0600` through a root one-off container, then run `scripts/article-transfer.mjs import` with `ARTICLE_IMPORT_ENABLED=true` and `--confirm-article-import`. The importer creates a production database backup before opening the database, resolves the article owner by stable ID or username, rejects ID and slug conflicts, verifies image checksums, and appends `editorial_content / news.promoted`. Remove both bundle copies, restart the app and scheduler, and health-check both services. ### Pull production ISAC data into development Manually run **Export production ISAC data** in Gitea Actions and download its `shd-production-isac-data` artifact. Stop the local development server, then: ```sh npm run isac:import -- /path/to/isac-production.json --confirm-replace-isac-data ``` The importer creates a complete local database backup and an ISAC-only snapshot before replacing canonical game data. It leaves accounts, content, proposals, notifications, settings, and audit history untouched. ### Pull production ISAC data into staging Manually run **Replace staging ISAC data from production** in Gitea Actions. The workflow exports a consistent production snapshot, restarts production, then stops staging and imports it. The staging importer creates full and ISAC-only safety backups before replacement. Deployments do not run this flow. ### Promote managed images without replacing other data Image Library assets use stable IDs and can be promoted additively from local development. Export a bundle after finishing local uploads and metadata edits: Set the server-only `REMOVE_BG_API_KEY` under `/app` in each Infisical environment that accepts ISAC uploads. The server sends it only in the `X-Api-Key` header. Transparent icon uploads skip remove.bg. Opaque sources use its preview output, then pass through local trimming, white normalization, WebP conversion, and storage. The application stores a remove.bg capacity snapshot and refreshes it from the internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup interval and defaults to six hours. Low-capacity alerts require both remaining pools to cross their thresholds: `REMOVE_BG_LOW_CREDIT_THRESHOLD` defaults to five paid credits, and `REMOVE_BG_LOW_FREE_CALL_THRESHOLD` defaults to ten free calls. Alerts are mandatory ISAC notifications for active Administrators and are deduplicated for each warning or critical episode. ```sh npm run images:export ``` The command writes a timestamped JSON bundle under `data/backups/`. It contains only managed-image metadata and processed WebP files. Transfer that bundle to the deployment host, stop the target `app` and `scheduler` services so the embedded database is unlocked, and run the merge using the target image: ```sh # Staging cd /opt/shd-build-stage docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app docker compose --env-file .env.deploy -f compose.staging.yml create app app_container=$(docker compose --env-file .env.deploy -f compose.staging.yml ps --all --quiet app) docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json" docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \ --entrypoint sh --user root app -c \ 'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json' docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps app \ node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \ --entrypoint rm --user root app -f /app/data/.managed-images-import.json docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler # Production cd /opt/shd-build-production docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app docker compose --env-file .env.deploy -f compose.production.yml create app app_container=$(docker compose --env-file .env.deploy -f compose.production.yml ps --all --quiet app) docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json" docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \ --entrypoint sh --user root app -c \ 'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json' docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps app \ node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \ --entrypoint rm --user root app -f /app/data/.managed-images-import.json docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler ``` Each import creates a full target database backup before changes. The importer adds new stable IDs, skips identical assets, and blocks conflicting records. After reviewing an intentional metadata or file replacement, add `--update-existing`. Imported changes append `image.imported` or `image.import_updated` audit events. Remove the transferred bundle after verifying both environments because it may contain unpublished visual assets and creator identifiers. The staging and production npm import commands fetch `/app` from Infisical. Run them only where the target database and storage paths are mounted: ```sh npm run images:import:staging -- /path/to/bundle.json --confirm-merge npm run images:import:production -- /path/to/bundle.json --confirm-merge ```