Skip to content

fix(docker): backup volume, atomic site-build, tag after gates, guide fixes (#166) - #170

Merged
Madraka merged 5 commits into
mainfrom
claude/stoic-jennings-52168b
Nov 8, 2026
Merged

Madraka merged 5 commits into
mainfrom
claude/stoic-jennings-52168b

Conversation

@Madraka

@Madraka Madraka commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #166. Every item of the issue is in this PR, rebased on #169 (#165), whose setup-token flow it keeps. Nothing under packages/ changes, and no changeset is needed: deploy/docker, docs and scripts/ are not published packages.

Nothing here was built or run as an image locally, by agreement (the build machine freezes under load). The PR's own Docker workflow builds the amd64 image and runs the smoke test and Trivy on it. The full workflow (digest-first push, both smoke tests, the tag job) is verified by the 0.6.4 release run right after this merges. There is no 0.6.3 re-tag: the smoke test asserts #169's setup-token refusal, which only 0.6.4 has, so a 0.6.3 image can never pass the gate. Until 0.6.4 is out, latest is the old 0.6.3 image, and main's compose file and Caddyfile work with it: the served path is /data/dist in both layouts.

Items and how each was verified

1. Backup volume (high). The compose volume is now name: ${CLOUDHAL_VOLUME:-cloudhal-data}, so the stack, docker run -v cloudhal-data:/data and the guide's backup commands share one volume.

  • A stack made with the 0.6.3 file has <project>_cloudhal-data. The guide, .env.example and compose tell operators to find it with docker volume ls and set CLOUDHAL_VOLUME to it, and that a second stack on the same host needs a name of its own.
  • Backup stops the container first and checks the archive with tar tzf.
  • Restore now stops the stack and unpacks into an emptied volume (or a new name). The earlier wording unpacked over a running database, leaving a newer data.db-wal beside the old data.db.
  • Verified: docker compose config with no .env, with .env.example, with CLOUDHAL_VOLUME set, and with the caddy profile.

2. Atomic site-build, with /data/dist kept as the served path.

  • cloudhal build has no output option: it always writes <project>/dist and empties it first (packages/cli/src/commands/astro.ts). So site-build never builds in /data. It copies the project's own files to /data/.site-build, links node_modules (its real target) and the data folder (cloudhal.json dataDir) so the build reads the same store, and builds there.
  • The finished build moves to /data/site/<build id>. Then site/current, site/previous and /data/dist (a symlink to site/current) are each replaced with ln -sfn + mv -T, which is atomic. A failed build changes nothing, and the stage is removed on exit. flock allows one build at a time.
  • A 0.6.3 volume's real dist folder moves to site/legacy-0.6.3 on the first build and becomes site/previous, so the prune keeps it, and its pages' assets, for one more build. That run has one moment without /data/dist, between two renames; bookworm's coreutils has no mv --exchange, and the docs say so. A mount point at /data/dist (a host bind mount) is refused before anything is written, using mountpoint -q, else a stat -c %d comparison. Stale dist.*, site/current.* and site/previous.* links from an interrupted run are removed first. An old Caddyfile (root /srv/cloudhal/dist), an nginx root of /data/dist, and the 0.6.3 image with main's Caddyfile all keep working.
  • The build before the current one is kept as site/previous; older builds are removed.
  • Verified locally: four runs of the final script on a project scaffolded by this checkout's CLI, with the starter's node_modules standing in for the image's and GNU mv/flock from Homebrew.
    • Run 1 started from a 0.6.3-style real dist and replaced it with the symlink.
    • Runs 2 and 3 swapped current and previous and kept exactly two builds.
    • Run 4 had a broken astro.config.mjs and left current, previous and dist unchanged.
    • The first attempt caught two real bugs, fixed before the commit: *.cloudhal (for archives) matched the data folder .cloudhal in the stage rules, and a chain of pnpm-style relative links broke Tailwind's resolution.
    • For the last round, three more local runs: the migration (the 0.6.3 folder became previous with its marker, and a stale dist.123 link was removed), the next build (the legacy folder was pruned), and the mount-point refusal with a stub mountpoint (nothing written, no stage left).
    • Also bash -n and shellcheck. The smoke test creates a real /data/dist with a marker before the first build. It then checks that /data/dist resolves under /data/site/, that site/previous holds the marker, the second swap, and the kept builds.

3. Tag after gates (docker.yml).

  • build pushes linux/amd64 + linux/arm64 with SBOM and provenance under sha-<short> only, then runs the amd64 smoke test and Trivy against $IMAGE@digest.
  • smoke-arm64 runs the smoke test on the native ubuntu-24.04-arm runner. QEMU was not used: a Node start plus an Astro build under emulation would take well over the ~10 minutes the brief allows, while the native runner is free for public repositories.
  • tag runs only after both. It adds the tags with docker buildx imagetools create --tag … from the single tested digest. One source and no annotations means buildx copies the index as is, so the digest and its attestation manifests stay the same; the step reads the digest back and warns if it ever differs. It then writes attest-build-provenance for that final digest.
  • Tag rule: X.Y.Z always; X.Y when the version is the highest patch of that minor among the repository's v* tags plus itself; latest when it is the highest release of all. A release finished by hand still gets latest, and a backfill of an older patch moves nothing. edge builds get edge only. Pull requests build amd64, smoke-test and scan, with no push, arm64 or tag.
  • Verified: actionlint. The tag computation was run in bash for six cases: the newest release built again (0.6.3 in the test data), a backfill of 0.6.2, a hand-finished 0.6.4, 0.6.4 after a 0.7.0, a two-digit 0.10.0, and no tags at all. The 0.6.4 release run verifies the jobs end to end. There is no 0.6.3 dispatch: a 0.6.3 image cannot pass the smoke test's setup-token check.

4. BETTER_AUTH_SECRET reaches the container as a bare name, so it is passed only when set, as are CLOUDHAL_AI_TOKEN and CLOUDFLARE_ACCOUNT_ID. The entrypoint treats an empty value as unset, so the secret on the volume stays in use. Verified: docker compose config shows the names absent when unset and present when set.

5. Mail claim. Invites and resets are links made on Studio's Team page. The guide's limits now document cloudhal.json mail: with it, the container prints each mail to its logs. Verified against studio.ts, mail-local.ts and routes/invites.ts.

6. No :? in the caddy profile. Caddy's command checks both origins when it starts and exits with a message. docker compose up without the profile no longer needs them. Verified: docker compose config with and without the profile, and with empty Caddy variables.

7. Secrets wording. .env.example and the guide name the secrets (BETTER_AUTH_SECRET, CLOUDHAL_AI_TOKEN, CLOUDHAL_SETUP_TOKEN), say chmod 600 and keep the file out of git. To keep them out of a checkout: a second env file named in COMPOSE_ENV_FILES for every compose command. A one-off --env-file is not enough, because the next up would drop the secrets.

8. CLOUDHAL_PORT / CLOUDHAL_BASE_URL.

  • The comments couple the two. CLOUDHAL_BASE_URL must be exactly the origin browsers open Studio at, and is required behind a proxy.
  • After fix(cli,api): self-host auth hardening (#165) #169, compose's ${CLOUDHAL_BASE_URL:-http://localhost:8787} default hid the CLI's refusal of --behind-proxy without a base URL: a proxied stack silently got localhost and a 403 on every sign-in. Compose now passes it with no default, and .env.example ships it empty, so unset means the CLI's own behaviour (sign-in from the browser's address, with a warning).
  • The entrypoint also refuses CLOUDHAL_BEHIND_PROXY=1 with a localhost base URL, as kept in a .env from 0.6.3. Verified: compose config, and the entrypoint run with a stub cloudhal in five cases.

9. Site origin for the static build.

  • The guide explains that the build takes the origin from the site's primary domain (cloudhal.json domains.site, cloudhal domain set).
  • site-build sets it from CLOUDHAL_SITE_URL, which compose now passes to the cloudhal service.
  • An origin with a path or port, a dotless name such as localhost, an IP address or an example.* placeholder is skipped with a message, and the build goes on.
  • The guide says that a published snapshot naming another primary domain keeps it until the next publish.
  • Verified: the parsing snippet was run on seven inputs, against the core-ts hostname rule.

10. Dockerfile example is 0.6.4, the first version the gates accept; the guide and README say so too.

11. Critic gaps.

  • The guide no longer says "published only". Before the first publish the build renders the latest snapshot; cloudhal build --published is RFC-0043 PR 2.
  • The Caddyfile marks /media/*, /_astro/* and /_cloudhal/* immutable, but only for files that exist, so a miss is never cached for a year.
  • /404.html exists only when the site has a /404 page; the guide and Caddyfile note this.
  • The guide describes what the static _headers really contains.
  • The "previews are visible" claim is replaced: the editor previews drafts, and signed preview links belong to the Cloudflare deployment.
  • The Caddyfile's root is /srv/cloudhal/dist again. A hashed asset (/_astro/*, /_cloudhal/*, /media/*) that the current build lacks is served from site/previous, with the same immutable Cache-Control, so a page opened before a swap still loads. docker.yml validates the Caddyfile in caddy:2.11 before building.
  • Verified: caddy validate and caddy fmt with Caddy 2.11.9 from Homebrew, because the Docker daemon is stopped on this machine. That Caddy served the four-run build tree: / returned 200, a current asset 200 with the immutable header, an asset only in previous 200, a missing asset 404 without a cache header, and /.cloudhal/auth-secret, /../.cloudhal/data.db and /site/previous/… 404.

12. Smoke test. It keeps #169's flow: the script makes its own token, passes -e CLOUDHAL_SETUP_TOKEN, expects 403 without the x-cloudhal-setup-token header and 200 with it. After the sign-up it checks GET / (200, text/html, <title>Cloudhal Studio</title>) and an anonymous POST /mcp (401). It also runs site-build twice, and --platform selects the architecture. Verified: Biome and node --check. The script runs in CI only.

From #169's review, in the guide:

  • The Quick start uses the one-time setup link from docker compose logs cloudhal, or CLOUDHAL_SETUP_TOKEN: 32+ characters of letters, digits and - . _ ~.
  • CLOUDHAL_SETUP_TOKEN is in the environment table, compose and .env.example.
  • An own reverse proxy must overwrite or append X-Forwarded-For, whose last hop is the rate-limit address, with an nginx example; Caddy does this by default.
  • --no-auth is listed as unusable in the container.

Also changed: CONTRIBUTING.md's Docker paragraph (the tag rule) and deploy/docker/README.md.

Review

Four read-only adversarial reviewers covered the workflow, site-build, compose/Caddy and the docs/smoke test. Their nine confirmed findings are fixed in the first commit: the Caddy miss caching, the one-off --env-file, a second stack on one volume, site-build stopping on a localhost origin, deleting the previous build too early, the restore order, the 0.6.3 upgrade without pull, and the _headers wording. The completeness critic did not finish.

Checks

  • pnpm check: green (exit 0) on the final commit. Biome (2792 files), size check, font fallbacks, MCP tools doc, demo archive and typecheck (20/20) passed.
  • The test step (19/19 tasks) came from turbo's cache, as in the previous run. This PR changes no package input (deploy/, docs/, scripts/, .github/), so turbo replayed passing results for identical inputs instead of running them again. An earlier full run of this diff, before the rebase, stopped on two Miniflare ECONNRESET errors in @cloudhal/analytics, a package not touched here; it was a load flake.
  • Also run: docker compose config (no .env, .env.example, the caddy profile with variables set and empty, CLOUDHAL_VOLUME set); bash -n and shellcheck on bin/site-build and bin/entrypoint; actionlint 1.7.12 on docker.yml; caddy validate and caddy fmt; the local site-build and Caddy runs above.
  • Not run: the image build and scripts/docker-smoke.ts. The PR's Docker workflow and the 0.6.4 release run cover them.

Upgrading from 0.6.3 (in the guide)

  1. Set CLOUDHAL_VOLUME=<project>_cloudhal-data.
  2. Fix the 0.6.3 .env's CLOUDHAL_BASE_URL (behind a proxy the new entrypoint refuses a localhost one) and its placeholder CLOUDHAL_SITE_URL before anything starts.
  3. Download compose.yml, the Caddyfile (rmdir Caddyfile if Docker made a folder; the previous-build fallback needs the new file) and .env.example to compare, never over .env.
  4. Run docker compose pull, then check the image named by docker compose config --images with docker run --rm --entrypoint grep … -c site/current /opt/cloudhal/bin/site-build. That check never touches the named volume. A 0 means the image is still 0.6.3: the new files work with it, and the layout changes with the first 0.6.4 build.
  5. Start the stack.

The site stays at /data/dist throughout. The quick start notes that the setup link needs 0.6.4: until then, keep Studio on 127.0.0.1 and create the Owner first.

Review of #170 (second round)

The project manager's review (24 agents, each finding checked twice) upheld eight findings; all are fixed in 66d19724:

  • A. No 0.6.3 re-tag (above).
  • B. /data/dist stays the served path (item 2).
  • C. The previous build's hashed assets are served after a swap (item 11).
  • D. COMPOSE_ENV_FILES takes full paths (relative ones resolve against the current directory). The cron line is cd /path/to/cloudhal && docker compose exec -T cloudhal site-build, which needs no env files.
  • E. The quick start and upgrade download the Caddyfile.
  • F. The image check runs after pull, without a volume.

Review of #170 (third round)

Fixed in 31251ae6:

  1. Item 3 names the 0.6.4 release run.
  2. The 0.6.3 folder becomes site/legacy-0.6.3 = previous. There is a mount-point preflight and stale-link cleanup, and the docs say "except for a moment on the first build after upgrading from 0.6.3".
  3. previous assets are immutable, and the Caddyfile is validated in CI.
  4. The smoke test covers the migration.
  5. The guide has the version note, the upgrade order, the config --images check, what a 0 means, the .env.example download, the rmdir Caddyfile hint, and the widened build warning.

🤖 Generated with Claude Code

Madraka and others added 5 commits November 8, 2026 14:03
… fixes (#166)

- compose.yml names the volume `cloudhal-data` (CLOUDHAL_VOLUME keeps a
  0.6.3 stack's `<project>_cloudhal-data`); the guide's backup, restore and
  upgrade steps match it for compose and plain `docker run`.
- site-build renders into /data/dist, then publishes the tree under
  /data/site/<id> and swaps /data/site/current with one rename; a failed
  build changes nothing, the previous build is kept, one build at a time.
  CLOUDHAL_SITE_URL becomes the site's domain (cloudhal.json domains.site)
  so canonical links, the sitemap and feeds use it.
- docker.yml pushes `sha-<short>` only, runs the smoke test (amd64 and a
  native ubuntu-24.04-arm job) and Trivy on that digest, then a `tag` job
  adds X.Y.Z, X.Y and latest with `imagetools create`, computed from the
  highest v* tag, and attests the final digest.
- compose passes BETTER_AUTH_SECRET, CLOUDHAL_AI_TOKEN and
  CLOUDFLARE_ACCOUNT_ID only when set; the caddy profile checks its origins
  when Caddy starts instead of `:?`; the Caddyfile serves site/current and
  marks existing /media/*, /_astro/*, /_cloudhal/* files immutable.
- Guide, .env.example, Dockerfile: invites and resets are links (the `mail`
  setting documented), secrets and COMPOSE_ENV_FILES, CLOUDHAL_PORT with
  CLOUDHAL_BASE_URL, the site's origin, latest-snapshot builds before the
  first publish, the 404 page, no preview links on Node, 0.6.3 example.
- docker-smoke.ts checks GET / (Studio), POST /mcp without credentials
  (401) and two site-build runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
… a proxy (#166)

Wording for the #165 hardening (PR #169):
- Quick start: the first owner signs up through the one-time setup link
  printed to the logs; CLOUDHAL_SETUP_TOKEN to choose it. compose.yml and
  .env.example pass and document CLOUDHAL_SETUP_TOKEN.
- CLOUDHAL_BASE_URL is required with CLOUDHAL_BEHIND_PROXY=1; compose now
  keeps an explicitly empty value (`${VAR-default}`), so a LAN install can
  accept the browser's own address instead of a localhost-only 403.
- Own reverse proxy: overwrite or append X-Forwarded-For (its last hop is
  the rate-limit address), with an nginx example; `--no-auth` cannot run
  in the container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
#166)

`cloudhal studio` now refuses --behind-proxy without --base-url (#169), but
compose's `${CLOUDHAL_BASE_URL:-http://localhost:8787}` default hid that:
a proxied stack that forgot the base URL silently got localhost and failed
every sign-in with 403.

- compose passes CLOUDHAL_BASE_URL with no default and .env.example ships
  it empty: unset, sign-in accepts the browser's own address and the log
  warns; behind a proxy the CLI refuses to start.
- The entrypoint refuses CLOUDHAL_BEHIND_PROXY=1 with a localhost base
  URL (a .env kept from 0.6.3), for `studio` only.
- The guide's base-URL and proxy rows say it is required behind a proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…s, upgrade steps (#166)

Review of #170 (project manager):
- site-build no longer moves /data/dist away. `cloudhal build` has no
  output option and empties <project>/dist, so the build runs in a staging
  copy of the project (/data/.site-build: files copied, node_modules and
  the data folder linked, so the same store is read). The finished build
  goes to /data/site/<id>; `current`, `previous` and /data/dist (a symlink
  to site/current) are each replaced with `ln -sfn` + `mv -T`. A 0.6.3
  real dist moves aside once. An old Caddyfile, an nginx root of
  /data/dist and the 0.6.3 image with the new Caddyfile keep working.
- The Caddyfile's root is /srv/cloudhal/dist again; hashed assets the
  current build lacks come from site/previous.
- No 0.6.3 re-tag: the smoke test needs 0.6.4's setup token. Guide,
  CONTRIBUTING, Dockerfile and README say 0.6.4 and later.
- COMPOSE_ENV_FILES with full paths (relative ones follow the current
  directory); the cron line runs `cd … && docker compose exec`, which
  needs no env files.
- The caddy-profile quick start and the upgrade download the Caddyfile;
  the upgrade checks the pulled image with `docker run --entrypoint grep`
  (no volume mounted) after `docker compose pull`.
- docker-smoke.ts reads the site through /data/dist and checks the
  current/previous links.

Verified locally: four site-build runs on a scaffolded project (a 0.6.3
real dist first, two swaps, a failing build that changed nothing) and the
Caddyfile served from that tree with Caddy 2.11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…ade order (#166)

Last review round of #170 (project manager):
- site-build: a real /data/dist (0.6.3) moves to site/legacy-0.6.3 and
  becomes site/previous, so the prune keeps it for one build. A mount point
  at /data/dist is refused before anything is written (mountpoint -q, else
  stat -c %d). Stale `dist.*`, `site/current.*` and `site/previous.*` links
  from an interrupted run are removed first.
- Caddyfile: assets served from site/previous are immutable too.
  docker.yml validates the Caddyfile in caddy:2.11 before building.
- docker-smoke.ts: a real /data/dist with a marker before the first build;
  afterwards /data/dist resolves under /data/site/ and site/previous holds
  the marker.
- Guide and README: the one moment without /data/dist on the first build
  after 0.6.3; the setup link needs 0.6.4 (until then keep Studio on
  127.0.0.1 and create the Owner first); upgrade steps in order (fix the
  base and site URLs before starting, download .env.example to compare,
  check the image named by `docker compose config --images`, what a 0
  means, the fallback needs the new Caddyfile, `rmdir Caddyfile`); no
  Astro build (`cloudhal build`, `cloudhal deploy`) inside /data.

Verified locally: the migration run (legacy folder kept as previous with
its marker, a stale link removed), the next run (legacy pruned), the
mount-point refusal (nothing written), and Caddy 2.11.9 serving a
previous-only asset with the immutable header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
@Madraka
Madraka merged commit d48a95a into main Nov 8, 2026
12 checks passed
@Madraka
Madraka deleted the claude/stoic-jennings-52168b branch November 8, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docker/self-host: backup volume name, atomic site-build, tag-after-gates in docker.yml, guide and compose fixes

1 participant