Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 136 additions & 29 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
name: Docker image

# The official self-host image, ghcr.io/cloudhal-dev/cloudhal (issue #143, docs/guides/self-host.md).
# Built from the npm release, never from the tree, so the image is exactly what npm users get:
# - release.yml calls this after a successful npm publish (`workflow_call` with the version):
# tags X.Y.Z, X.Y, latest and sha-<short>;
# - Actions › Docker image › Run workflow with a version re-publishes or backfills it (X.Y.Z, X.Y
# and sha-<short>; `latest` never moves on a dispatch, so a backfill cannot point it at an
# older release); with no version it builds `edge`;
# - nightly, `edge` is built from main's own packed tarballs (scripts/pack-packages.ts): tagged
# edge and sha-<short> only, never latest;
# Built from the npm release, never from the tree, so the image is exactly what npm users get.
# Three jobs, so that no release tag moves before the gates pass (#166):
# - `build` builds linux/amd64 + linux/arm64 with an SBOM and buildx provenance, pushes it under
# the candidate tag `sha-<short>` only, then runs scripts/docker-smoke.ts on the amd64 image
# (empty volume, health check, Studio's page, an anonymous `/mcp` call, the first owner through
# the API, a publish, `site-build` twice) and Trivy, which fails the job on a CRITICAL
# vulnerability with a fix;
# - `smoke-arm64` runs the same smoke test on the arm64 image, on GitHub's native arm64 runner
# (free for public repositories; under QEMU a Node process and an Astro build take far longer
# than the ten minutes that would be worth);
# - `tag` adds the release tags to the very digest that passed, with `imagetools create`, and then
# writes the GitHub build attestation for it.
# Which tags `tag` adds:
# - release.yml calls this after a successful npm publish (`workflow_call` with the version), and
# Actions › Docker image › Run workflow with a version re-publishes or backfills one. X.Y.Z
# always; X.Y when the version is the highest patch of that minor among the repository's v*
# tags; `latest` when it is the highest release of all. So a release started by hand still gets
# `latest`, and a backfill of an older patch moves nothing backwards.
# - with no version (a dispatch without one, and nightly) `edge` is built from main's own packed
# tarballs (scripts/pack-packages.ts) and tagged edge only, never latest;
# - a pull request that touches the image builds it for linux/amd64 without pushing and runs the
# same smoke test and scan.
# Every push is linux/amd64 + linux/arm64 with an SBOM, buildx provenance and a GitHub build
# attestation; scripts/docker-smoke.ts then runs the amd64 image (empty volume, health check, the
# first owner through the API, a publish, `site-build`), and Trivy fails the job on a CRITICAL
# vulnerability with a fix. Versions before 0.6.3 have no `--init-if-empty`: the image needs it.
# amd64 smoke test and the scan.
# Versions before 0.6.4 cannot pass the gates: the smoke test expects the first owner's setup
# token (#165), which 0.6.4 brought, and the image needs `--init-if-empty` (0.6.3).

on:
workflow_call:
Expand Down Expand Up @@ -49,15 +59,15 @@ env:
IMAGE: ghcr.io/${{ github.repository_owner }}/cloudhal

jobs:
image:
name: ${{ inputs.version && format('Image {0}', inputs.version) || 'Image edge' }}
build:
name: ${{ inputs.version && format('Build {0}', inputs.version) || 'Build edge' }}
runs-on: ubuntu-latest
timeout-minutes: 90
permissions:
contents: read
packages: write
id-token: write
attestations: write
outputs:
digest: ${{ steps.build.outputs.digest }}
env:
VERSION: ${{ inputs.version || '' }}
PUSH: ${{ github.event_name != 'pull_request' }}
Expand All @@ -68,6 +78,21 @@ jobs:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
# The compose file's Caddyfile, in the Caddy the compose file runs (a few seconds).
- name: Validate the Caddyfile
run: |
docker run --rm \
-e CLOUDHAL_BASE_URL=https://studio.example.com \
-e CLOUDHAL_SITE_URL=https://www.example.com \
-v "$PWD/deploy/docker/Caddyfile:/etc/caddy/Caddyfile:ro" \
caddy:2.11 caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
- name: Check the version
if: env.VERSION != ''
run: |
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version must be x.y.z (got '$VERSION')"
exit 1
fi

# edge: the packages of this ref, built and packed the way the release job packs them
- if: env.VERSION == ''
Expand All @@ -83,17 +108,13 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# The candidate tag only; the release tags come from the `tag` job once the gates pass.
- id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
flavor: latest=false
tags: |
type=semver,pattern={{version}},value=v${{ env.VERSION }},enable=${{ env.VERSION != '' }}
type=semver,pattern={{major}}.{{minor}},value=v${{ env.VERSION }},enable=${{ env.VERSION != '' }}
type=raw,value=latest,enable=${{ env.VERSION != '' && github.event_name != 'workflow_dispatch' }}
type=raw,value=edge,enable=${{ env.VERSION == '' }}
type=sha,prefix=sha-
tags: type=sha,prefix=sha-
labels: |
org.opencontainers.image.title=Cloudhal
org.opencontainers.image.description=Agent-native site platform: Studio, API and MCP server, self-hosted
Expand All @@ -118,12 +139,6 @@ jobs:
# attestations need a registry push; a loaded local image takes none
sbom: ${{ env.PUSH == 'true' }}
provenance: ${{ env.PUSH == 'true' && 'mode=max' || 'false' }}
- if: env.PUSH == 'true'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true

- id: ref
run: |
Expand All @@ -144,3 +159,95 @@ jobs:
ignore-unfixed: true
exit-code: "1"
format: table

smoke-arm64:
name: Smoke test (linux/arm64)
needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: node scripts/docker-smoke.ts --image "$IMAGE@$DIGEST"
env:
DIGEST: ${{ needs.build.outputs.digest }}

tag:
name: ${{ inputs.version && format('Tag {0}', inputs.version) || 'Tag edge' }}
needs: [build, smoke-arm64]
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: write
id-token: write
attestations: write
env:
VERSION: ${{ inputs.version || '' }}
DIGEST: ${{ needs.build.outputs.digest }}
steps:
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: tags
name: Which tags this digest gets
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ -z "$VERSION" ]; then
tags="edge"
else
# Every released version: the repository's v* tags (release.yml pushes one per release)
# plus this version itself, which has none yet when a release is finished by hand.
# `grep` always matches at least this version, so the pipelines never come up empty.
released=$( { gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" --jq '.[].ref' | sed 's#^refs/tags/v##'; echo "$VERSION"; } \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -uV)
minor="${VERSION%.*}"
highest=$(printf '%s\n' "$released" | tail -n 1)
highest_of_minor=$(printf '%s\n' "$released" | grep -E "^${minor//./\\.}\.[0-9]+$" | tail -n 1)
tags="$VERSION"
[ "$highest_of_minor" = "$VERSION" ] && tags="$tags $minor"
[ "$highest" = "$VERSION" ] && tags="$tags latest"
fi
echo "tags=$tags" >> "$GITHUB_OUTPUT"
echo "Tagging $IMAGE@$DIGEST as: $tags" | tee -a "$GITHUB_STEP_SUMMARY"
- id: push
name: Tag the digest that passed
env:
TAGS: ${{ steps.tags.outputs.tags }}
run: |
set -euo pipefail
args=()
for t in $TAGS; do args+=(--tag "$IMAGE:$t"); done
# One source and no annotations: the index is copied as it is, so the digest stays the
# one the smoke tests and Trivy saw. Read back and checked, and the attestation below
# names what the tags really point at.
docker buildx imagetools create "${args[@]}" "$IMAGE@$DIGEST"
first="${TAGS%% *}"
final=$(docker buildx imagetools inspect "$IMAGE:$first" --format '{{.Manifest.Digest}}')
echo "digest=$final" >> "$GITHUB_OUTPUT"
if [ "$final" != "$DIGEST" ]; then
echo "::warning::$IMAGE:$first is $final, not the tested $DIGEST"
fi
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ All publishable packages (`@cloudhal/*` and `create-cloudhal`) share one version
- **cloudhal-template** (the Deploy to Cloudflare button): the workflow's `template` job (`.github/workflows/template-sync.yml`) opens `chore: Cloudhal X.Y.Z and the X.Y.Z starter theme` from `cloudhal/vX.Y.Z`, with `src/`, `public/`, `astro.config.mjs` and `tsconfig.json` from the published starter, `@cloudhal/*` at `^X.Y.Z` and a lockfile that installs exactly `X.Y.Z`. Merge it once its `cloudhal/template-check` status is green: the same run installs a copy of the synced template with `npm ci` and runs `cloudhal deploy --dry-run` with `WORKERS_CI=1`. To sync again, run Actions › Template sync › Run workflow. Without the `TEMPLATE_SYNC_APP_CLIENT_ID` variable and the `TEMPLATE_SYNC_APP_PRIVATE_KEY` secret (a GitHub App installed on cloudhal-template only, with Contents, Pull requests and Commit statuses write) the job only logs a notice. Then sync by hand from a checkout: clone cloudhal-template next to this repository, run `node scripts/template-sync.ts --template ../cloudhal-template --version X.Y.Z --summary pr-body.md` and then the same command with `--lock` in place of `--summary` (Node 24, no `pnpm install` needed), check a copy of the template (`npm ci`, then `WORKERS_CI=1 npx cloudhal deploy --dry-run`), commit on `cloudhal/vX.Y.Z`, push, open the PR with the title above and `pr-body.md` as its body, and post the `cloudhal/template-check` status on its head commit (`gh api -X POST repos/cloudhal-dev/cloudhal-template/statuses/<sha> -f state=success -f context=cloudhal/template-check -f description='npm ci and cloudhal deploy --dry-run (WORKERS_CI=1) passed, run by hand'`). Dependabot's `@cloudhal/*` PR on the template is no substitute: it moves dependencies only, no theme, and it fails to install whenever the starter's own pins moved with the release (0.6.3 moved astro to 7.4.7 for the `^7.4.0` peer range, so Dependabot's `^0.6.1` PR hit ERESOLVE); close it as superseded by the sync PR.
- **cloudhal.com**: Dependabot opens the `@cloudhal/*` PR the next morning (06:00 Europe/Istanbul). A patch release merges itself after its check; a minor bump waits for a person.
- **try.cloudhal.com** (`cloudhal-dev/try`): by hand. Bump its exact `@cloudhal/*` pins, then `pnpm install` and `pnpm run deploy`.
- **The Docker image** (`ghcr.io/cloudhal-dev/cloudhal`, [self-host guide](docs/guides/self-host.md)): the workflow's `docker` job (`.github/workflows/docker.yml`) builds it from the npm release and pushes `X.Y.Z`, `X.Y` and `latest` for linux/amd64 and linux/arm64, with an SBOM, provenance and a smoke test. The image never carries a version of its own: one release, one version everywhere. To rebuild a version (a base-image fix) run Actions › Docker image › Run workflow with it; that re-tags `X.Y.Z` and `X.Y` and leaves `latest` alone. `edge` is built nightly from `main`.
- **The Docker image** (`ghcr.io/cloudhal-dev/cloudhal`, [self-host guide](docs/guides/self-host.md)): the workflow's `docker` job (`.github/workflows/docker.yml`) builds it from the npm release for linux/amd64 and linux/arm64 with an SBOM and provenance, pushes it under `sha-<short>` only, runs the smoke test on both architectures and Trivy against that digest, and only then tags it: `X.Y.Z` always, `X.Y` when the version is the highest patch of that minor among the `v*` tags, `latest` when it is the highest release of all. The image never carries a version of its own: one release, one version everywhere. To rebuild a version (a base-image fix) run Actions › Docker image › Run workflow with it (0.6.4 or later: older versions fail the smoke test's setup-token check); the same rule decides its tags, so a release finished by hand still gets `latest` and a backfill of an older patch moves nothing. `edge` is built nightly from `main`.

**If something fails:** use **Re-run failed jobs** on the failed run. The script skips packages npm already has and an existing tag or release, so it finishes a half-done release. A new run (Actions › Release › Run workflow) does not: once npm has every package, `plan` skips `publish`, so a missing tag or GitHub Release stays missing. To redo only the template PR, run Actions › Template sync › Run workflow. npm's trusted-publisher entry for each package names `release.yml` and the `npm-publish` environment: renaming either breaks publishing.

Expand Down
56 changes: 45 additions & 11 deletions deploy/docker/.env.example
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
# Copy to .env next to compose.yml. No secrets live here: Better Auth's secret is created on the
# volume at first start (/data/.cloudhal/auth-secret), and invites are printed to the logs.
# Copy to .env next to compose.yml (docs/guides/self-host.md). `docker compose` reads it and passes
# the values to the container. Treat it as private — `chmod 600 .env`, never commit it — since it
# holds secrets once Build chat is on (CLOUDHAL_AI_TOKEN) or BETTER_AUTH_SECRET or
# CLOUDHAL_SETUP_TOKEN is set. To keep
# secrets out of a checkout, put them in a second file outside it and name both, with full paths
# (relative ones resolve against the current directory), in COMPOSE_ENV_FILES for every command
# that creates the container (`up`, `pull && up`, `run`), e.g. in the shell profile:
# export COMPOSE_ENV_FILES=/path/to/cloudhal/.env,/etc/cloudhal/secrets.env
# A one-off `--env-file` is not enough: the next `up` without it drops the secrets. The cron line's
# `docker compose exec` runs in the running container and needs neither.

# The image tag. `latest` follows every release; pin the minor (`0.6`) in production.
CLOUDHAL_TAG=latest

# Where Studio is reached. Without a reverse proxy: this machine. Behind one (the caddy profile):
# the public https origin, and CLOUDHAL_BEHIND_PROXY=1 so the proxy's X-Forwarded-* headers count.
CLOUDHAL_BASE_URL=http://localhost:8787
# The volume that holds the installation (the project, its database, media, the built site). A
# stack created with the compose file of 0.6.3 has `<project>_cloudhal-data` (`docker volume ls`;
# `cloudhal_cloudhal-data` after the guide's quick start): name it here to keep using it. A second
# Cloudhal stack on this host needs a volume name of its own (and its own port and base URL).
#CLOUDHAL_VOLUME=cloudhal-data

# Where browsers open Studio: scheme, host and port, no path, e.g. http://localhost:9000 with
# CLOUDHAL_PORT=9000, http://192.168.1.20:8787 on the LAN, https://studio.example.org behind a
# proxy. Set, it is the only origin sign-in accepts (and the origin of invite links), so it must be
# exactly what the address bar shows: a wrong value leaves Studio loading but every sign-in
# answered 403. Empty: sign-in accepts whatever address the browser used, and the log warns.
# Required behind a reverse proxy (the caddy profile), with CLOUDHAL_BEHIND_PROXY=1 so the proxy's
# X-Forwarded-* headers count: Studio refuses to start with the proxy flag and no base URL (or,
# in this image, a localhost one).
CLOUDHAL_BASE_URL=
CLOUDHAL_BEHIND_PROXY=0
# Which address and port of this machine publish the container's port 8787 (0.0.0.0 to expose it
# directly; keep 127.0.0.1 with the caddy profile).
# directly; keep 127.0.0.1 with the caddy profile). A new port goes into CLOUDHAL_BASE_URL too.
CLOUDHAL_BIND=127.0.0.1
CLOUDHAL_PORT=8787

# The public site, served by Caddy from `site-build`'s output (caddy profile only).
CLOUDHAL_SITE_URL=https://www.example.com
# The public site's origin (https://host, no path), once the site has a domain: Caddy serves
# `site-build`'s output there (caddy profile), and `site-build` writes the host into canonical
# links, the sitemap and feeds (cloudhal.json `domains.site`). Leave it empty until then.
CLOUDHAL_SITE_URL=

# The first start on an empty volume creates a project from this starter with this site name.
CLOUDHAL_INIT=blog
Expand All @@ -24,6 +46,18 @@ CLOUDHAL_SITE_NAME=My site
CLOUDHAL_AUTOMATIONS=on
# Extra origins the API accepts browser requests from, comma-separated (normally empty).
CLOUDHAL_TRUSTED_ORIGINS=
# Workers AI over REST for Build chat (RFC-0016): both or neither.
CLOUDHAL_AI_TOKEN=
CLOUDFLARE_ACCOUNT_ID=

# Secrets, each passed to the container only when set here or in the shell.
# Better Auth's secret is created on the volume at first start (/data/.cloudhal/auth-secret) and
# kept there; set this only to supply your own (32 or more random characters). Never leave it empty.
#BETTER_AUTH_SECRET=
# The first owner's setup token. Empty: a token is generated at each start while there is no
# account, and the setup link (http://localhost:8787/login#setup=<token>) is printed to the logs
# (`docker compose logs cloudhal`). To choose it yourself: a random value of 32 characters or
# more, only letters, digits and - . _ ~ (`openssl rand -hex 32`); the log then shows the link
# with <CLOUDHAL_SETUP_TOKEN> in place of the value.
#CLOUDHAL_SETUP_TOKEN=
# Workers AI over REST for Build chat (RFC-0016): both or neither. The token (a Cloudflare API
# token with Workers AI read) stays in the container's memory; it is never written to the volume.
#CLOUDHAL_AI_TOKEN=
#CLOUDFLARE_ACCOUNT_ID=
Loading
Loading