# Container and single-server hosting

> Container image, host preparation, promotion and the docs.tune.toys static site.

Source: https://docs.tune.toys/contributors/hosting/

How to build the immutable API/browser image, prepare a single Linux host, and what must pass before inviting users. The step-by-step promotion itself is in [releases](https://docs.tune.toys/contributors/releases/); the current hosted state is in the [AWS pilot record](https://github.com/joshstovall/TuneToys/blob/main/docs/delivery/aws-pilot.md).

**Status:** Implemented foundation — the image and disposable container checks are implemented; the `tunetoys-pilot-live` CloudFormation stack in account `982471468586`, region `us-east-2`, is running its Lightsail instance with a pinned image. Launch gates below remain open. Fargate, RDS, CloudFront, a registry and always-on staging are deferred per the [lean Lightsail plan](https://github.com/joshstovall/TuneToys/blob/main/docs/architecture/aws.md).

Operator context: the local CLI profile is `tunetoys` (temporary console-session credentials); use the `josh@song.study` Chrome profile for browser sign-in. This is the Just Ship It project selected for the `hello@bit.audio` AWS account.

## 1. Build an immutable artifact locally

Docker is an explicit prerequisite; root checks do not start it. Build for the architecture of the selected server, not the developer laptop:

```sh
npm run build:container -- --platform=linux/amd64
npm run test:container
```

- Linux amd64 is the default; `--platform=linux/arm64` is also admitted. Cross-architecture execution requires Docker emulation. The official Node multi-architecture index, PostgreSQL fixture image and public RDS trust bundle are pinned in [the image lock](https://github.com/joshstovall/TuneToys/blob/main/infra/container/base-images.json); the RDS bundle is available for a later deployment and is not the trust anchor for a private local PostgreSQL certificate.
- The build prepares the shared Rust WASM on the build machine and copies those exact bytes into the image. It freezes admitted source files into a private temporary snapshot, checks hashes inside Docker, and runs the contract generation checks whose sources are in the snapshot and the production browser build there (the Rust-formatted node manifest and the Markdown-sourced node docs and `llms` files are checked by `npm run check` instead; see [the container README](https://github.com/joshstovall/TuneToys/blob/main/infra/container/README.md#build)). It never copies `.env`, local account databases, caches, private signing material or another repository. Runtime uses the production dependencies of the API and the listing-asset worker (`services/worker`, which the API imports at start and runs in-process only with `TUNETOYS_WORKER_INLINE=1`), user 1000 and a read-only-root compatible filesystem. Native macOS plugins build separately.
- `.cache/containers/<input-sha>/<run-id>/build.json` records source/platform, engine digest, immutable image ID and snapshot location. The frozen context stays beside the receipt. `.cache/containers/latest.json` advances only if the checkout still matches after the build; otherwise the command exits nonzero and retains the exact receipt/image. Pass that receipt explicitly to `node tools/test-container.mjs /absolute/path/build.json` when intentionally qualifying an older snapshot. A latest receipt is local bookkeeping, not a release or deployment authorization.
- The smoke runner uses disposable containers, unique networks and synthetic accounts. It checks that a hosted image refuses to start without PostgreSQL, then a non-root/read-only TLS PostgreSQL journey (the API no longer has a SQLite mode): trusted CA and hostname, rejection of incorrect trust/hostname/password, non-superuser role bootstrap, repeat setup, account/project/publication, exact engine bytes, library, fork, initial HTML and real PNG rendering. It limits API containers to 768 MiB and PostgreSQL to 384 MiB and deletes only what it created. With `--smoke` it then loads home, Explore, a template, the published Toy and its embed renderer in Playwright's pinned Chromium headless shell, read-only. **It does not qualify a shared 2 GiB host, live S3/SES, public TLS, audio playback in a browser or a DAW.**

To transfer a reviewed image without a registry: `docker image save` the receipt's exact image ID, retain a SHA-256 digest of the archive beside the receipt, transfer through the operator's authenticated channel, verify the digest, `docker image load`, and compare `docker image inspect` identity, architecture and `toys.tune.*` labels with the receipt. Never deploy a floating tag. Retain the preceding archive/receipt for rollback.

## 2. Prepare the host

Provisioning and installation are separate operator steps. Confirm account, region, owned hostname, administrator access, budget and recovery contacts first.

1. Use a maintained Linux image and the platform's matching Docker engine, PostgreSQL 17 and Caddy packages. Mount the encrypted PostgreSQL data disk before initializing it; root-disk containers are not database persistence.
2. Configure PostgreSQL on loopback only with SCRAM and a server certificate with a verified loopback identity (for `127.0.0.1`, include that IP in its SAN). Keep its private key PostgreSQL-owned and mode `0600`. Store the public issuing CA at `/etc/tunetoys/database-ca.pem`; the container receives only this public CA. Do not enable fixture plaintext or disable certificate/hostname verification. Restrict web/administrator ingress in Lightsail; neither 8787 nor 5432 is public.
3. Create an empty dedicated database and separate application/migration roles through [database bootstrap](https://github.com/joshstovall/TuneToys/blob/main/docs/delivery/database.md#role-bootstrap). Apply verified migrations before starting the API. Limit PostgreSQL connections/memory for the shared host; the API defaults to eight pooled connections. Benchmark the full server and backup workload before inviting users.
4. Create root-owned mode `0600` service files outside the checkout:

   | File                              | Contents                                                                                                                                                                                                                                                                                                                                                    |
   | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
   | `/etc/tunetoys/image.env`         | Only `TUNETOYS_IMAGE=sha256:<the loaded image ID>` from the reviewed receipt                                                                                                                                                                                                                                                                                |
   | `/etc/tunetoys/api.env`           | Application-role database fields, `TUNETOYS_DATABASE_SCHEMA=tunetoys`, `TUNETOYS_DATABASE_CA_FILE=/run/database-ca.pem`, `TUNETOYS_PUBLIC_ORIGIN=https://<owned hostname>`, stable native authorization key, private S3 bucket/region and narrowly scoped server AWS credentials. Only configured provider settings. No administrative/migration passwords. |
   | Protected bootstrap/migration env | Available only during schema promotion. Back up the native authorization key through the operator's protected secret recovery process.                                                                                                                                                                                                                      |

   Docker environment files contain literal single-line `NAME=value` entries; do not add shell quoting or source them. Keep multiline provider material in a supported escaped form only after provider qualification. Root `.env` is local development configuration; never copy it to the server.

5. Media policy: the service forces `TUNETOYS_MEDIA_UPLOAD_POLICY=invited`. Add only trusted creators' real account UUIDs to `TUNETOYS_MEDIA_UPLOADER_IDS` in the protected API environment, then restart. Empty grants nobody upload permission. Users can register, create/share graphs, browse and reuse approved public media without raw-upload access. The browser's upload dialog reports this capability, and every reservation/write/completion enforces it independently. Existing signed S3 forms can write only staging bytes until expiry; finalization still requires current access. See [pilot media access](https://github.com/joshstovall/TuneToys/blob/main/docs/specs/media-library.md#hosted-pilot-access).
6. Review [the service and Caddy source](https://github.com/joshstovall/TuneToys/blob/main/infra/lightsail/README.md) before installing. The service uses Linux host networking, binds the application to loopback, and the API refuses to start without its PostgreSQL configuration. Set Caddy's `TUNETOYS_HOST` to the owned hostname, validate its configuration, point DNS at the approved static IP, and let Caddy obtain its certificate. The reverse proxy preserves API status codes, authorization cookies and dynamic publication HTML; it has no cached-account or SPA-error rewrite. The request limiter sees the loopback proxy, so its address quotas apply across visitors; shared-host load and edge policy remain qualification items.

## 3. Promote and verify

- Hold the exclusive `/srv/tunetoys/operator/backup-work/deployment.lock` throughout an image/schema/key promotion or rollback. The hourly backup uses the same lock and skips a locked run without emitting success. Preserve the lock's inode; removing/recreating the lock file can allow two operators to hold different locks. Confirm a fresh backup before promotion starts.
- Retain the current image and verified database/blob/secret backups before migration. Run the separate migration role, then start/restart the API service and check `/v1/health` over loopback and public HTTPS.
- Verify `/v1/media/upload-access` returns invited mode and denies a signed-in account outside the configured list; its upload reservation must return `403 MEDIA_UPLOAD_RESTRICTED`. An older image that lacks or ignores this policy cannot pass promotion.
- Exercise sign-in, owned drafts, private release denial, anonymous public playback, exact engine/assets, Player authorization/sync and configured email delivery. No native plugin is built on this host.

Automated promotion (v0.0.3, not yet installed on the pilot): [`promote.sh`](https://github.com/joshstovall/TuneToys/blob/main/infra/lightsail/README.md#automated-promotion) performs the steps above under the same lock with a compare-and-swap on `image.env`, receipt-label verification, a fresh confirmed backup, and automatic rollback of image-only releases. It refuses an image whose migration file set lacks any name the running image has (`downgrade-refused`) and allows `rollback` only with an unchanged set. Schema releases run the candidate's `tools/bootstrap-database.mjs` with the protected `bootstrap.env` (migration role and grant reconciliation) and are always rehearsed first with `tunetoys-promote rehearse` against a clone restored from the latest backup. The owner installs it with `infra/lightsail/install-promotion.sh` and the [owner checklist](https://github.com/joshstovall/TuneToys/blob/main/infra/lightsail/README.md#owner-checklist); until then the manual flow is the only path.

The exact sequence, version identity rules and rollback limits are in [releases](https://docs.tune.toys/contributors/releases/). PostgreSQL requires an exact migration ledger: even additive schema migrations need a tested recovery path because an older image cannot restart against the new ledger. No push, build or check automatically deploys production.

> **Pending:** the pilot database still carries the retired 001–017 migration history. It adopts `0000_baseline.sql` on the next schema promotion (`tunetoys-promote promote --kind schema`); until then do not promote an image that cannot adopt, and expect rollback to a pre-baseline image to be refused afterward. See [adopting the baseline](https://github.com/joshstovall/TuneToys/blob/main/docs/delivery/database.md#adopting-the-baseline).

## 4. Recovery

Back up PostgreSQL consistently with `pg_dump`, preserve role definitions separately, and retain versioned private S3 bytes plus the native key. An upload of an archive is not a restore qualification. Rehearse a replacement host with a restored database, media, key and pinned image; verify existing accounts, releases and Player grants before declaring recovery ready. Database rollback requires an explicitly rehearsed compatible backup/migration path; changing an image alone cannot undo a schema/data change.

## Documentation site

`docs.tune.toys` is a static site built from the repository by [`apps/docs`](https://github.com/joshstovall/TuneToys/blob/main/apps/docs/README.md) (`npm run build:docs`, validated by `npm run check:docs`; neither is part of root `npm run check`). It needs no credentials and no database.

**Status:** Live at [docs.tune.toys](https://docs.tune.toys/) on the existing pilot as of November 2, 2026, with 226 pages for app `0.1.1`; public DNS and verified HTTPS pass. The installer and block pass `npm run test:docs-install` on Ubuntu 24.04 with Ubuntu's Caddy 2.6.2; all 49 docs tests pass. See the [deployment record](https://github.com/joshstovall/TuneToys/blob/main/docs/delivery/aws-pilot.md).

Cost boundary: docs reuse the existing Caddy process, static IPv4, disk and included Lightsail transfer; build and package locally. There are no new AWS resources or fixed hosting charges. Snapshot storage and transfer above the bundle allowance remain usage charges; retain only needed release directories on the shared data disk.

Path split on one hostname ([site block](https://github.com/joshstovall/TuneToys/blob/main/infra/lightsail/docs.caddy)):

- `/@*` (published creator documents and storefront pages) and the shared asset paths those pages use (`/v1/*`, `/assets/*`, `/previews/*`, `/social/*`) are proxied to the loopback API. The API renders them with the shared Markdown renderer so edits and takedowns apply immediately and are never frozen into a static build.
- Everything else is served from `/srv/tunetoys/docs/current`. `file_server` redirects `/nodes/std.delay` to `/nodes/std.delay/`; unknown paths return the generated `404.html`.
- Cache: HTML and directory indexes `max-age=300, must-revalidate`; shared assets, search index and feeds one hour; `/v/*` snapshots one day.

Publish (the operator runs these; nothing deploys automatically):

1. **Once:** in GoDaddy DNS add `docs` → `A` → `3.20.14.74` (the pilot's static IP `tunetoys-pilot-ip`), TTL 600. Caddy obtains the certificate on its own once the name resolves to the host.
2. On the release commit, with built contracts (`npm run build:contracts`): `npm run package:docs`. It builds strictly (any docs check problem stops it) and writes `.cache/docs/tunetoys-docs-<app-version>-<content hash>.tar.gz` and its `.sha256`. `apps/docs/package.json` `version` is the app version the docs describe; bump it with the release.
3. Copy both files to the host through the operator's channel, then, in that folder: `sha256sum -c tunetoys-docs-….tar.gz.sha256 && tar -xzf tunetoys-docs-….tar.gz && sudo ./tunetoys-docs-…/install-docs.sh`. The installer places an immutable `/srv/tunetoys/docs/releases/<bundle>/`, switches `/srv/tunetoys/docs/current` atomically, installs [`docs.caddy`](https://github.com/joshstovall/TuneToys/blob/main/infra/lightsail/docs.caddy) as `/etc/caddy/docs.caddy`, adds `import /etc/caddy/docs.caddy` to `/etc/caddy/Caddyfile` once, runs `caddy validate` and reloads. If Caddy rejects the configuration it restores both files and Caddy keeps serving its running configuration. It reports whether `https://docs.tune.toys/` answers on the host.
4. Rollback: `sudo ./install-docs.sh --rollback` from any bundle repoints `current` at the release that was live before. It is independent of database rollback.

Versioning: each build contains the current docs at `/` and an immutable copy at `/v/<app-version>/`. A new build keeps earlier `/v/<version>/` directories that exist in the previous `dist` (copy the previous release's `v/` folder into the new `dist` before archiving, or build in place). `/versions.json` holds the `current` pointer and the version list; snapshots show a banner when they are no longer current. `npm run package:docs` builds into a fresh folder and so carries only the current version's snapshot; before the second app version ships, seed it with the previous release's `v/` (planned).

## Launch gates

Before invitations: qualify the implemented upload-access policy and hosted limits/failures; configure live identity/email; verify backup restoration; install failure/spend alerts; review rights/legal/security; run the [browser/DAW validation gate](https://github.com/joshstovall/TuneToys/blob/main/docs/delivery/validation.md). Untrusted public uploads require isolated decoder/ingestion qualification. **These gates remain open; the container is a deployable foundation, not a claim of a launched service.**

## Related

- [AWS pilot record](https://github.com/joshstovall/TuneToys/blob/main/docs/delivery/aws-pilot.md)
- [Database runbook](https://github.com/joshstovall/TuneToys/blob/main/docs/delivery/database.md)
- [Release process](https://docs.tune.toys/contributors/releases/)
- [Infrastructure definitions](https://github.com/joshstovall/TuneToys/blob/main/infra/README.md)
- [Trust and service boundaries](https://docs.tune.toys/contributors/security/)
