Skip to content

docs: cloud deployment IaC design (OpenTofu + cloud-init + Hetzner) - #138

Merged
evangineer merged 35 commits into
mainfrom
feat/cloud-deployment-iac
Apr 21, 2026
Merged

docs: cloud deployment IaC design (OpenTofu + cloud-init + Hetzner)#138
evangineer merged 35 commits into
mainfrom
feat/cloud-deployment-iac

Conversation

@evangineer

@evangineer evangineer commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Proposes an Infrastructure-as-Code approach for deploying Holochain hApps using Edge Node on Hetzner Cloud + Cloudflare. Covers the full stack: Cloudflare Pages/Workers (joining service + log-collector), Hetzner VMs (edgenode + harvester), h2hc-linker, Caddy TLS.

What's included

  • deploy/DESIGN.md — full design with architecture, DR, open questions, and spike findings (Appendix A)
  • deploy/.env.example — secrets template
  • deploy/spike/ — working OpenTofu + esbuild spike answering Cloudflare Worker deployment questions

Key design decisions

  • Hetzner persistent volumes — hard requirement for lair keystore and conductor database survival; volumes are managed independently of VMs in OpenTofu
  • cloud-init over Ansible — cloud-init is sufficient for current scope; Ansible deferred until operational complexity warrants it (rolling updates across many nodes, config drift correction)
  • VM count — 2 edgenodes + 1 harvester for staging (3 VMs total); N edgenodes for production
  • Harvester VM — runs conductor (Unyt hApp) + log-harvester together; conductor is required for zome-call signing and invoice creation via @holochain/client
  • Harvester bootstrap — one-time operator step via a Docker bootstrap container (ghcr.io/holo-host/edgenode-bootstrap); generates agent key, whitelists it in the joining service, installs Unyt hApp; agent key is stable for the lifetime of the persistent volume
  • Disaster recovery — daily Hetzner volume snapshots; RTO < 10 min (VM failure), < 30 min (volume restore); RPO < 24 hours

Spike findings (Appendix A)

  • log-collector: deploys via OpenTofu. KV and D1 bindings work. Use Cloudflare provider v5.x.
  • joining service: stays on wrangler deploy@holo-host/lair → libsodium WASM can't be bundled with esbuild alone; wrangler handles it transparently
  • Cross-directory imports: esbuild resolves them fine

Open questions (unresolved)

  • Platform-track: self-registering harvester requires a machine-to-machine admin API on the joining service (Appendix B)
  • @holo-host/lair libsodium WASM fix — needed before joining service can move to OpenTofu deployment

Test plan

  • Review architecture, data persistence, and DR sections
  • Confirm VM count and sizing for staging
  • Review harvester bootstrap approach
  • Forward libsodium WASM options to @holo-host/lair developer
  • Forward platform self-registration requirement to joining service developer

🤖 Generated with Claude Code

evangineer and others added 20 commits March 19, 2026 12:32
Introduces a new container variant (Dockerfile.harvester) that bundles
log-harvester (unytco/log-harvester) instead of log-sender. The harvester
reads from log-collector, aggregates usage data, and parks invoices on
Unyt Agreements via an embedded Holochain conductor.

Key changes:
- Dockerfile.harvester: single-stage wolfi-base build, clones and builds
  log-harvester from source via GITHUB_TOKEN build secret, bakes in
  unyt.happ from latest unytco/unyt-sandbox release (pinnable via
  UNYT_HAPP_VERSION build arg), exposes ports 4444 and 4445
- s6-overlay-harvester/: self-contained s6 service tree (conductor,
  log-harvester, logrotate-cron, setup) with no dependency on the base
  s6-overlay directory
- log-harvester s6 service: waits for conductor readiness, installs
  unyt.happ, attaches app websocket on 4445, init/refreshes harvester
  config, runs harvester in loop mode
- CI: build-and-push-harvester-image job publishing
  ghcr.io/holo-host/edgenode-harvester using HARVESTER_REPO_TOKEN secret
- docker-compose.yml: edgenode-harvester service for local testing
- Docs: LOG_HARVESTER_QUICKSTART.md, updated README.md, docker/README.md,
  docker/CHANGELOG.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds BATS test files and a test runner for the harvester container variant:

- harvester_startup.bats: verifies conductor start, unyt.happ install,
  app-ws on 4445, config initialization, and log-harvester service start
- harvester_process.bats: verifies holochain and node run as nonroot
- run_harvester_tests.sh: runner analogous to run_tests_multi.sh, builds
  the harvester image with GITHUB_TOKEN secret and waits for full startup
- pr-checks.yml: adds test-harvester-image CI job running on docker/** PRs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds harvester_e2e.bats which submits real signed metrics via log-sender,
verifies they reach D1, then runs the harvester (--today --dry-run) and
asserts "fetched metrics count" > 0 and "Successfully invoiced logs."

Trims harvester_integration.bats to two lightweight connectivity checks.
Extends run_harvester_tests.sh to start edgenode and wait for its
Holochain conductor before running the e2e test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CONFIG_PATH: /etc/log-harvester → /data/log-harvester (volume-mounted,
  survives restarts; drops now-redundant holo-config-harvester volume)
- s6 run scripts: shebang → #!/command/with-contenv so HC_* env vars
  are available inside the service
- log-harvester/run: remove add-app-ws call (handled by harvester init);
  add chown after both init and refresh paths
- Dockerfile.harvester: GITHUB_TOKEN secret now optional — falls back to
  unauthenticated clone when git credentials already grant access
- pr-checks.yml: switch to docker/build-push-action with GHA layer cache
  for edgenode, edgenode-harvester, and log-collector builds
- run_tests_multi.sh: build log-collector separately before compose up
  to preserve layer cache across repeat runs
- harvester_startup.bats: update config path and websocket test to match

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The docker/log-collector directory is gitignored (it's a separate repo).
Add an actions/checkout step to clone it into place before the build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The log-collector Dockerfile and entrypoint are ours, not upstream's.
Store as Dockerfile.log-collector in the docker/ directory so CI can
find them after checking out unytco/log-collector as the build context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
secret-envs is not a valid build-push-action parameter; the correct
field is secrets which mounts the value directly as a build secret.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
build-push-action secrets: does not reliably mount the github_token
secret into the BuildKit RUN --mount. Revert to an explicit
docker buildx build run: step with --secret id=github_token,env=GITHUB_TOKEN,
which is the known-working approach. GHA layer cache flags are retained.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rets

Docker BuildKit secret mounting is unreliable in GHA. Mirror the
log-collector approach: checkout unytco/log-harvester into
docker/log-harvester-src/ before the build, then COPY it in.

- Dockerfile.harvester: replace RUN --mount=type=secret git clone
  with COPY log-harvester-src
- pr-checks.yml: add actions/checkout step for log-harvester
- run_harvester_tests.sh: auto-clone log-harvester-src if absent
- .gitignore: add docker/log-harvester-src/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docker compose up -d was starting all services including edgenode-harvester,
which requires log-harvester-src in the build context. Explicitly name only
the services needed for the edgenode test suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In CI the wrangler state directory is empty (no persistent local volume),
so drone_registrations and other tables don't exist. Run wrangler d1 execute
--local --file=schema.sql in the entrypoint before starting wrangler dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrangler dev reads vars from wrangler.toml, not Docker env vars.
Pass --var ADMIN_SECRET:${ADMIN_SECRET} so the worker picks up the
secret configured in docker-compose.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
run_tests_multi.sh was running all *.bats files including harvester_*,
which fail when edgenode-harvester isn't started. Exclude harvester files
explicitly — they belong to run_harvester_tests.sh.

Also add setup() skip guards to harvester_startup.bats and
harvester_process.bats which were missing them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add restart: unless-stopped to log-collector service so Docker
  auto-recovers when wrangler dev crashes under concurrent D1 load
- Tighten healthcheck (15s interval, 5 retries, 30s start_period)
- Run each .bats file separately in run_tests_multi.sh with a
  log-collector health-check wait between files, so a wrangler crash
  in integration_data_pipeline.bats doesn't cascade to later files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CHANGELOG: add all Unreleased entries for harvester variant work
  (Dockerfile.log-collector, run_harvester_tests.sh, BATS files,
  test runner isolation, restart policy)
- TESTING: note per-file test isolation and harvester_* naming convention
- LOG_HARVESTER_QUICKSTART: remove stale --secret flag from local build
  snippet; replace with git clone of log-harvester-src (required for COPY)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dockerfile was renamed/consolidated; latest-unyt tag is not published.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds deploy/DESIGN.md describing a full-stack IaC approach for deploying
Holochain hApps using Edge Node on Hetzner Cloud with Cloudflare for the
joining service, UI, and log-collector. Covers staging/production workflow,
env-var-based secrets pattern, tooling rationale, and operator adaptation
guide. Includes deploy/.env.example as the secrets template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Bundle h2hc-linker and Caddy into the standard edgenode image as
  toggleable s6-overlay services, eliminating the need for cloudflared
  or separate systemd services on the VM
- Caddy provides TLS termination directly via Let's Encrypt, giving the
  linker a stable public URL (no tunnel, no seed-kv on restart)
- harvester variant explicitly excludes linker and caddy
- Generalise the CONDUCTOR_MODE toggle pattern to all services
- Drop Ansible roles for linker and tunnel (now managed via container
  env vars); simplify to edgenode and harvester roles only
- Add CLOUDFLARE_ZONE_ID and CADDY_DOMAIN to env var table
- Resolve open questions on cloudflared (dropped) and tunnel stability

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@evangineer
evangineer requested a review from zippy April 3, 2026 12:20
evangineer and others added 6 commits April 3, 2026 18:42
S3-compatible with endpoint override, keeps all infrastructure costs
on Hetzner rather than splitting across providers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Minimal build + OpenTofu config to answer three questions before
committing to full IaC implementation:

1. Can cloudflare_worker_script handle esbuild-bundled TypeScript Workers?
2. Can it resolve the joining service's cross-directory imports?
3. Do KV (joining service) and D1 (log-collector) bindings work via the provider?

See deploy/spike/README.md for steps and success criteria.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use NODE_PATH env var instead of invalid --node-paths flag
- Add --external:node:* (CF nodejs_compat handles these at runtime)
- Add --external:@holo-host/lair (membrane proof, not needed for spike)
- Add --external:buffer (dynamic require in @bitgo/blake2b-wasm)
- Make MEWSFEED_DIR and JOINING_SERVICE_DIR configurable env vars;
  joining-service is a sibling repo, not inside mewsfeed
- Both bundles now build: joining.js (149kb), log-collector.js (1.2mb)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces deprecated cloudflare_worker_script with the current three-resource
pattern: cloudflare_worker + cloudflare_worker_version + cloudflare_workers_deployment.

Updates bindings to use the unified bindings array with type field (kv_namespace,
d1, plain_text, secret_text) instead of separate binding blocks. Uses content_file
to avoid bloating state with large bundle content.

Source: https://developers.cloudflare.com/workers/platform/infrastructure-as-code/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Upgrade Cloudflare provider to v5.x (v4.x lacks cloudflare_worker,
  cloudflare_worker_version, cloudflare_workers_deployment)
- Add nodejs_compat compatibility flag to joining worker version
- Bundle @holo-host/lair instead of externalising it; build lair from
  the holo-web-conductor workspace before running esbuild
- Add read_replication and subdomain fields to resolve provider drift
- Resolve DESIGN.md open question 2: log-collector deploys via OpenTofu;
  joining service stays on wrangler deploy due to libsodium WASM issue
- Add Appendix A with full spike findings and options for @holo-host/lair

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@zippy zippy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading over the design doc it seem pretty reasonable, there's one problem, and I just have one questions:

Problem: it looks like the design show the log-harverster running on a node by itself which it can't. It needs a conductor running on the same node to which it connects to actually create the invoices. It does so via the js @holochain/client node-js package. So one of the deployment issues is how that node is authenticated to the network via the joining-services CLI interface, and also how it is signing it's zome-calls. For us this hasn't been decided I don't think, it could happen a few different ways.

Question: is this the right time to be adding ansible into the mix? It seems like it might be an extra layer that isn't actually needed right now because we could just be using a simple cloud-init file for deployment on Hetzner. On the other hand, it may be right to add it from the start. Just want to confirm the thinking.

@evangineer

Copy link
Copy Markdown
Contributor Author

it looks like the design show the log-harverster running on a node by itself which it can't. It needs a conductor running on the same node to which it connects to actually create the invoices. It does so via the js @holochain/client node-js package.

FYI the harvester edgenode actually runs conductor + Unyt hApp + log-harvester.

The doc is mistaken and will be corrected.

evangineer and others added 2 commits April 9, 2026 15:00
- Add Data Persistence section: Hetzner persistent volumes as a hard
  requirement for lair keystore and conductor database survival
- Replace Ansible with cloud-init + shell scripts; document rationale
  and deferral of Ansible
- Fix harvester description: runs conductor (Unyt hApp) + log-harvester,
  not log-harvester only
- Add Disaster Recovery section: backup strategy (Hetzner volume
  snapshots), three recovery scenarios, RTO/RPO objectives
- Clarify architecture: single joining service shared by hApp and Unyt,
  single harvester per deployment, 2 edgenodes for staging
- Resolve open questions: VM count, VM sizing, state backend, bootstrap
- Add open question: platform-track self-registering harvester
- Replace Makefile with scripts/ directory; remove make from workflow
- Fix open question numbering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the joining service admin API approach for automated harvester
bootstrap at platform scale, including endpoint design, KV-backed agent
store, cloud-init integration, and scope of changes required. Option A
(manual bootstrap script) remains in place for the staging PoC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@evangineer
evangineer requested a review from zippy April 9, 2026 14:15
- Remove Ansible section (SSH_KEY_PATH → SSH_PUBLIC_KEY)
- Add CLOUDFLARE_ZONE_ID and SSH_PUBLIC_KEY variables
- Add HARVESTER_LAIR_PASSWORD for harvester conductor lair keystore
- Complete TF_VAR passthrough for all new variables
- Update source instructions to reference deploy scripts (not make/ansible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@evangineer evangineer changed the title docs: cloud deployment IaC design (OpenTofu + Ansible + Hetzner) docs: cloud deployment IaC design (OpenTofu + cloud-init + Hetzner) Apr 17, 2026
@evangineer

Copy link
Copy Markdown
Contributor Author

Thanks for the review @zippy — both concerns are addressed in the current branch HEAD:

Conductor requirement: The design already reflects this correctly. The harvester VM table shows conductor (Unyt hApp), log-harvester as co-located services, and open question #5 covers the bootstrap process — generating the agent key, whitelisting it in the joining service, and installing the Unyt hApp so the conductor can sign zome calls via lair. The earlier version of the doc was ambiguous; it's been corrected.

Ansible: The design explicitly defers Ansible in favour of cloud-init — there's a 'Why not Ansible (for now)' section covering the reasoning. For the current scope (a small number of VMs each running one container) cloud-init is sufficient and keeps the toolchain simpler. Ansible is noted as a candidate if operational needs grow (rolling updates across many nodes, config drift correction). The PR title and description have been updated to remove the Ansible reference.

Let me know if anything else needs clarification.

evangineer and others added 3 commits April 17, 2026 15:55
These three files were modified during the exploratory spike before
PR #137 landed fixes. Reset to main:
- Dockerfile.harvester: remove redundant mkdir -p /app
- docker-compose.yml: remove dead build secrets block
- run_harvester_tests.sh: restore GITHUB_TOKEN clone support and error handling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds two new optional s6-overlay services to the standard edgenode image:

- h2hc-linker: auto-enabled when H2HC_LINKER_ADMIN_SECRET is set; waits
  for the conductor admin port before starting; reverse-proxied by Caddy
- caddy: auto-enabled when CADDY_DOMAIN is set (and H2HC_LINKER_ADMIN_SECRET
  is set); generates a Caddyfile at runtime and terminates TLS via
  Let's Encrypt for the linker's public endpoint

Both services use exec tail -f /dev/null when their toggle variable is
absent, keeping s6 happy without treating the disabled state as a crash.

Dockerfile changes:
- apk add caddy
- arch-aware h2hc-linker download (LINKER_VERSION=0.1.0)
- EXPOSE 80 443 added alongside existing 4444

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tier 1 (always run in CI — no env vars needed):
- h2hc-linker and caddy binaries are installed
- neither process runs when H2HC_LINKER_ADMIN_SECRET / CADDY_DOMAIN unset
- ports 80 and 443 are not bound in the disabled state

Tier 2 (skipped unless services are configured):
- h2hc-linker runs as nonroot and listens on H2HC_LINKER_PORT
- caddy runs, listens on 443, generates /tmp/Caddyfile with the domain
- startup.log contains expected messages for both services

Tier 2 tests activate automatically when the services are running;
run via a compose override that sets H2HC_LINKER_ADMIN_SECRET and CADDY_DOMAIN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@evangineer evangineer self-assigned this Apr 17, 2026
@evangineer
evangineer requested a review from SIR-R0B April 21, 2026 13:24
evangineer and others added 2 commits April 21, 2026 14:30
…ames

- Bump LINKER_VERSION 0.1.0 → 0.1.1 in Dockerfile
- Fix asset names: x86_64-unknown-linux-gnu → linux-x86_64 style
- Rewrite linker/run: use env vars only (no CLI flags), require
  H2HC_LINKER_BOOTSTRAP_URL to enable, default H2HC_LINKER_ADDRESS=0.0.0.0
- Fix caddy/run enable condition: H2HC_LINKER_ADMIN_SECRET → H2HC_LINKER_BOOTSTRAP_URL
- Update DESIGN.md and linker_caddy.bats for new API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add linker and caddy rows to the services table with auto-enable conditions
- Add 'Optional services' subsection explaining the idle/toggle mechanism,
  env vars for both services, and example docker run commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@evangineer
evangineer merged commit eb583ad into main Apr 21, 2026
2 checks passed
@evangineer
evangineer deleted the feat/cloud-deployment-iac branch April 21, 2026 14:28
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.

2 participants