ADR 0032: The learner labs platform — a Terraform-managed Hostinger host under Azure Arc, Docker only, Coder as the learner boundary, and public submission locked to the site's pane¶
Status: Accepted 2026-09-27 (owner: "all have been approved to move forward"); amended 2026-09-26 and 2026-09-28; amendment of 2026-09-29 (Vault auto-unseal, #726) accepted by the owner and live on the host the same day; amendment of 2026-10-14 (off-host backups, LAB-9, #955) chosen by the owner that day. Decision 6 was revised on 2026-09-28: public submission is open, only from the Landing Zone Builder's pane on the site, locked by the request's origin and a Cloudflare Turnstile token, within decision 6's original bounds, which are unchanged. Also on 2026-09-28 the site began embedding Coder, in a pane on each lab's page, which decision 4 had ruled out and the alternatives had rejected (amendment of that date, #750 and #751). On 2026-10-26 the visitor-session sweep gained a timer, at the owner's choice (#1403; amendment of 2026-10-17, the paragraph "Amended 2026-10-26"). Decision date: 2026-09-25 Owners: Workload owner and architecture owner
Context¶
The estate has a Hostinger VPS that nothing manages. vps-agent/ is a
pull-based Node job runner that dials out to the Functions API, claims a job,
runs it in docker run --network none against a digest-pinned image, and posts
the result back. It is admin-only: enqueueLabJob in functions/src/lib/labs.js
requires the editor role, and the public submission path the source
repository had (submitPublicLabJob) was deliberately not ported. No host is
provisioned (Required inputs §4.7),
and until this record the target architecture
§5.3 described a browser-submitted labs flow that does not exist.
Owner direction 2026-09-24: the VPS is an empty host, any OS. Rather than a
Kubernetes lab, make it the on-premises half of a real hybrid estate —
provisioned by Terraform, configured by Ansible, onboarded to Azure Arc so it
appears in the same tenant as the production Azure estate, monitored by the
existing Log Analytics workspace, and shown live on a public page. It then
hosts browser labs (Coder) and the existing vps-agent. Every step is a pull
request a follower can read and repeat for a few dollars a month.
Four epics build on this record: the lab host itself (#656), the Landing Zone
Builder (#657), the hcw-lab image and Docker sandbox (#658), and browser labs
on Coder (#659). Each of their sub-issues assumes the decisions below, so they
are recorded once, here, before any of them is implemented.
Purpose and decision drivers¶
- Blast radius. A lab experiment must never be able to touch production Azure state, production secrets, or the site's sign-in. The host is third-party, reachable from the internet, and will run learner-supplied code.
- Cost, against the USD 150/month ceiling of ADR 0015. Hostinger is billed outside Azure (cost analysis). The Azure side must add nothing with a standing charge that the estate cannot already absorb.
- Teachability. The point of the host is that a follower can repeat it. A design that needs a Kubernetes distribution, a VPN gateway or a paid Defender plan is one fewer people will reproduce.
- Supply chain. Learner-facing images are pulled implicitly by
docker run, before--network nonetakes effect. Whatever runs on the host must be pinned by digest, built from this repository, and reviewed like any other change (the rulevps-agent/lib/capabilities.jsalready enforces). - Security and operational excellence. Least privilege on every identity the host holds; outbound-only where a protocol allows it; the existing alerting fabric (ADR 0022) rather than a second one.
Decision¶
- The VPS is Terraform-managed through the
hostinger/hostingerprovider (v0.1.22, January 2026) in its own HCP Terraform workspace. Organisationhcw, workspacehcw-lab, VCS-driven from this repository with working directoryinfra-lab/, auto-apply off. Thehostinger_api_tokenandcloudflare_api_tokenit needs are workspace variables, never repository values. Lab state andhcw-azurestate never meet: nothing ininfra-lab/readshcw-azureoutputs, and nothing ininfra/readshcw-lab. - Ubuntu 26.04 LTS, and Docker Engine is the only runtime on the host.
(Owner decision 2026-09-26: the VPS was reinstalled with 26.04 LTS, the latest LTS, and stays on it; this line read 24.04 LTS until then.) No
Kubernetes of any size (owner decision 2026-09-24; the earlier k3s idea is
dropped). Coder and its PostgreSQL run as containers under Docker Compose.
(Owner decision 2026-09-26: Coder's database stays this PostgreSQL container on the host, not Coder's built-in PostgreSQL or Azure Database for PostgreSQL Flexible Server B1ms, about $14 a month in compute;
lab-host/ansible/roles/coder/README.md, "Where it runs", records both.) Caddy runs host-native from a pinned build that includes thecaddy-dns/cloudflaremodule, because neither the stock package nor the official image carries it and DNS-01 needs it; its version and checksum are pinned inlab-host/.vps-agentruns host-native as a systemd service, because it drives thedockerCLI itself and a container that drives the host daemon is no more contained than a service that does; its certificate at/etc/hcw/labs-agent.pemisroot:hcw-labs-agent0640, readable by the service user and by nothing else. node-exporter is a host-native service too; the Arc agent runs as a host service because that is how Azure ships it. - Azure Arc-enabled servers is the hybrid control plane. Microsoft Learn
lists Ubuntu 26.04 on x86-64 as a supported Arc-enabled servers operating
system, and Ubuntu 26.04 LTS as supported by the Azure Monitor Agent (both
read 2026-09-26). The host is
onboarded as an Arc machine in a new resource group,
rg-lab-hybrid-prod-cus, in the application subscription. Onboarding uses a service principal holding only Azure Connected Machine Onboarding on that resource group; its credential lives in Ansible Vault and never in the repository or on the host after onboarding. An Azure Monitor Agent data collection rule sends heartbeat andauth/authprivsyslog only into the existing Log Analytics workspace inrg-mgmt-plat-prod-cus(Management subscription). Machine configuration policy is audit only (and, since the amendment of 2026-10-07 under Consequences, has no agent to run it: guest configuration is off on the host). Defender for Servers stays off for cost. Arc itself is free. The public lab page reads the host's Arc state through the Function App, never from the browser: the Function App's existing managed identity is granted Reader onrg-lab-hybrid-prod-cusonly (anazurerm_role_assignmentininfra/, #664) and queries Azure Resource Graph for themicrosoft.hybridcompute/machinesrow (status, last heartbeat, agent version) and its policy compliance; the result is cached for a minute intool_service_cacheand answers{ configured: false }when the resource group does not exist, so the card shows an explicit absent state rather than a fabricated one. No new credential is involved; the role assignment is the whole grant. - Coder (Community edition) is the learner identity boundary. It runs
from Docker Compose on the host with Docker-based workspaces, and learners
sign in to it with GitHub OAuth, and only members of a GitHub
organisation the owner names may sign in:
CODER_OAUTH2_GITHUB_ALLOWED_ORGSis a required setting, not an option, because an absent or empty value lets any GitHub account consume the public VPS. It is therefore never empty while Coder runs, and the Ansible role refuses to render an empty value. The kill switch is an explicit stop, not an emptied list:CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=falsecloses the door to new learners while existing ones keep working, and stopping thecoderCompose service closes it to everyone (Caddy then answers 503 for the Coder names). (Owner decision 2026-10-16, #954: only thelab-learnersteam of that organisation signs in,CODER_OAUTH2_GITHUB_ALLOWED_TEAMS, andCODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=falseis now the normal setting rather than a switch: no sign-in creates an account, and the owner creates each learner's account before their first sign-in. Shutting one learner out is taking them out of the team and suspending their Coder account; stopping thecoderservice is still the switch for everyone, and an emptied list is still neither. Labs host runbook, "Coder sign-in: adding and removing a learner".) Two things on the host may drive the Docker daemon, and nothing else (a third, Portainer, owner-only and on the loopback, since the amendment of 2026-09-26 below): the Coder server container, which has the socket mounted because that is how Coder's documented Docker install creates workspaces, andvps-agent, which runs host-native as thehcw-labs-agentsystemd service with its user in thedockergroup, because its runner executes thedockerCLI (vps-agent/lib/docker-runner.js). A workspace never receives the socket, a privileged flag or a host path, and the template test in #679 asserts that. The privilege boundary this leaves is recorded under consequences. (Since the amendment of 2026-10-07, LAB-5: the agent is in nodockergroup and reaches Docker through its own allowlisting proxy, the daemon remaps user namespaces, and the Coder server reaches a separate rootless daemon where every workspace runs.) The site never signs learners in and never embeds Coder:frontend/staticwebapp.config.jsonkeepsframe-srcat'self'plus the Entra sign-in origin and a closedconnect-src. The site links out tolab.hybridcloudworks.comand shows lab status through a server-side proxy in the Function App, whose app settingsCODER_URLandCODER_STATUS_TOKENare Key Vault references to the secretsCODER-URLandCODER-STATUS-TOKENinkv-site-prod-cus-01(vault names are hyphenated; the naming table in Variables and secrets applies). (Owner decision 2026-09-28: the lab is reached only through panes on the site, which reverses "never embeds Coder" above and the iframe alternative rejected below; the lab side is recorded in Labs host, "Panes only", and the site side in the amendment of that date.frame-srcnow admits Coder's two names;connect-srcstays closed and the status proxy is unchanged.) - One toolchain, published as digest-pinned images from a new
lab-image/directory. The images go to GHCR (and to Docker Hub once an organisation exists there) and are the single toolchain for the lab pages, the Coder template andvps-agent. They carry a Terraform provider filesystem mirror and the Azure Verified Modules the Landing Zone Builder emits, vendored at pinned versions under/opt/avm/<module>@<version>, soterraform initsucceeds under--network none:initdownloads both providers and registrymodulesources, and a mirror alone still fails on the modules. Today theterraform-validatecapability invps-agent/lib/capabilities.jsrunsterraform init -backend=falseinside a network-less container, so it can pass only for HCL that declares neither a provider nor a registry module; the mirror plus the vendored modules is what makes a real landing-zone configuration validatable. The learner's files are never changed for this: the builder's download and the submitted payload keep registrysourceandversionlines, so the zip initialises anywhere with network, and theterraform-validatecapability's fixed command is to rewrite those sources to the vendored paths on its own tmpfs copy inside the job beforeinit. That rewrite does not exist yet: todayvps-agent/lib/capabilities.jscopiesmain.tfalone and runsinit, so on the runner image it validates HCL whose providers are mirrored and whose modules are absent or fully vendored. #675 lands the rewrite, the multi-file payload and the transitive vendoring thatavm-ptn-alzand the connectivity module need (measured in #686: one and thirteen registry child modules respectively); until then the full landing-zone output is not validatable offline and this record does not claim it is. These images are the learner and job toolchain only. The host's infrastructure services Coder and PostgreSQL run their upstream images, pinned by digest in the Compose file; Caddy,vps-agentand node-exporter are host-native, installed by Ansible at pinned versions with checksums. - Anonymous public lab submission stays Gated. Accepting this ADR does not
open it. When a later revision does, the bounds are these and no wider:
only the
terraform-validatejob type; a 64 KB payload; 2 submissions an hour per client and 50 a day globally; refused outright while more than 20 jobs are queued; jobs written withpublic: trueand a 1-day TTL. Until then, submission isenqueueLabJobfrom/admin/labsunder theeditorrole, exactly as the code stands. (Revised 2026-09-28, owner decision; the amendment of that date below. The Gate is replaced by a lock to the Landing Zone Builder's pane on the site, the request's origin and a Cloudflare Turnstile token, and every bound in this paragraph stands as written.enqueueLabJobunder theeditorrole is unchanged beside it.)
Amendment 2026-09-26: a clean reinstall; Portainer and Vault on the host, loopback-only¶
Context. The owner's first bootstrap.sh run on the VPS failed at the
agent's checkout, because /opt/hcw-labs-agent held a July install of the
old agent. The host ran Ubuntu 26.04 but had never been reinstalled, which
the note under decision 2 assumed it had. It ran two self-hosted
GitHub Actions runners, Portainer EE 2.39.4, an nginx site on port 80, k3s,
Vault 1.17.5 and that agent. Before failing, the run upgraded Docker from
29.6.0 to 29.8.1 and restarted it, which killed a running Dependabot job,
enabled ufw and wrote the sshd hardening. Nothing was deleted.
Owner decision 2026-09-26. Reinstall the VPS clean. Nothing on it was production, so nothing is exported. Portainer and Vault are part of the clean deployment.
bootstrap.shrefuses a host it was not prepared for. On a host it has never accepted, it looks for other workloads before it changes anything: any Docker container, an installedactions.runner.*unit, Kubernetes units or directories, a foreign checkout at the repository or agent path, anything else under/opt, and a TCP listener other than sshd's and systemd-resolved's. It refuses with what it found, and names a reinstall orHCW_ADOPT_NONEMPTY_HOST=1as the two ways on. The first run that passes writes/etc/hcw/bootstrap-host-accepted, and later runs skip the check. Each check would have caught the host above.- Portainer Business Edition, loopback only. The
portainerrole runs Portainer BE 2.45.1 (LTS) by index digest, with a named volume and the Docker socket, published on127.0.0.1:9443and nowhere else, with plain HTTP off and no Caddy route. The owner reaches it through an SSH tunnel. It runs under Portainer's 3 Nodes Free licence (internal business use, one server instance, up to three nodes; this is one of each). The licence key and the administrator are entered in its UI and are in neither the repository nor the Ansible vault. Off untilportainer_enabledis true. This amends decision 4's "two things on the host may drive the Docker daemon". Portainer is a third, and holding the socket makes it root on the host. That is bounded by who can reach it: only someone with an SSH login, which already carriessudo, so it adds no one who is not already root. A workspace or job container cannot reach it either: it listens on the host's loopback, and neither has host networking. - HashiCorp Vault, loopback only, for lab-host secrets only. The
vaultrole runs Vault 2.1.1 host-native under systemd as thevaultuser, with integrated (raft) storage in/var/lib/vault, the API on127.0.0.1:8200and raft's cluster port on127.0.0.1:8201, and TLS from a certificate generated on the host. The download is checked against HashiCorp's GPG signature on the release's SHA256SUMS. The role never initialises or unseals Vault.vault operator initand every unseal are owner steps over SSH, and the unseal keys and root token go to the owner's password manager, never to the repository, a log or the Ansible vault. Off untilvault_enabledis true. The boundary. This host runs untrusted learner workloads, and a workspace escape is root, which can read an unsealed Vault's memory. So this Vault holds lab-host secrets only and never a production HybridCloudWorks secret. Those stay in Azure Key Vaultkv-site-prod-cus-01, which nothing on the host can read. It holds nothing that exists nowhere else, so the host still holds no data of record. Follow-up, not built: auto-unseal with an Azure Key Vault key, through the Arc machine's managed identity, would end the owner step after every restart. It must use a lab-only key vault and a grant on one key, neverkv-site-prod-cus-01. That is #726, P3 on the board. (Accepted and live on the host 2026-09-29: the amendment of that date.)
Consequences of this amendment:
- Vault is sealed after every restart and every reboot, including the unattended-upgrades reboot at 04:30, until the owner enters three of the five unseal keys. Anything on the host that reads from Vault must tolerate a sealed Vault. (Superseded 2026-09-29 by #726: the host now auto-unseals, so a restart comes back unsealed while Key Vault answers. The amendment of that date.)
- The inbound policy is unchanged. Both new services listen on the
loopback, and nothing listens on a public address but sshd on 22 and Caddy
on 80 and 443. Because Docker's rules for a published port come before
ufw's, the
portainerrole refuses any publish address but127.0.0.1. - Portainer's licence is renewed yearly at no cost while the use stays within three nodes. Its terms forbid using it "to provide services to third parties". Portainer is the owner's tool here and learners never reach it. If that changes, the Community Edition image is the same release with no key.
Amendment 2026-09-28: decision 6 revised, open only from the site's pane¶
Context. #672 built the anonymous path behind decision 6's bounds and
left it closed (LABS_PUBLIC_SUBMISSION_ENABLED unset). The lab agent
vps-hostinger-01 is registered and heartbeating, so the only thing between
the Landing Zone Builder's "Validate on the lab" and a real job was this
decision. The consequences below named the question the revision had to
answer: which client identifier the rate limits count.
Owner decision 2026-09-28. "The lab should only be accessible through 'panes' from my site, lock to that." Of the shapes put to the owner, the one chosen is the site's origin plus Cloudflare Turnstile. The same decision has a lab-host half, recorded under decision 4 and in Labs host, "Panes only" (#750): Caddy lets only the site frame the lab and turns a direct visit away. This amendment is the API half, for the one public route that queues work.
- Public submission is open, and only from the builder's pane on the
site. Decision 6's Gate is replaced by a lock, and nothing else about
the decision moves:
terraform-validateonly, 64 KB, 2 an hour per client, 50 a day globally, refused while more than 20 jobs are queued,public: truewith a 1-day TTL, and refused outright while no agent registered for the type has heartbeated. The lock is checked before any of those, and before any store read or counter moves, so a refused request costs the store nothing and spends no one's quota (functions/src/lib/labs/public-lock.js,public-submit.js). - The origin. A
POST /api/public/labs/submitis refused403 ORIGIN_NOT_ALLOWEDunless itsOriginis exactlyhttps://hybridcloudworks.comorhttps://www.hybridcloudworks.com, the production origins of the CORS allowlist (functions/src/lib/auth/cors.js), both of which serve the site. A missing Origin is refused, and so are the Static Web App's preview hostname and localhost, which CORS admits for other reasons. On its own this stops browsers on other sites and nothing else, because a script can send any header. - A Cloudflare Turnstile token. The builder renders a Turnstile widget
(Managed mode, shown only when Cloudflare wants the visitor to act) once
the lab is open, and sends its token with the submission. The Function App
verifies it with Cloudflare's siteverify, passing the secret key and the
client address
client-identity.jstrusts, and accepts onlysuccess: truefor one of the site's two hostnames and the actionlab-validate. Tokens are single use and live five minutes, so a replay fails at siteverify. Siteverify out of reach, or refusing the secret, is503 TURNSTILE_UNAVAILABLE, never a pass. Because the per-client quota needs the store and so comes after the lock, an in-memory limit per Function App instance allows one client ten checks in ten minutes and answers the next429 TURNSTILE_RATE_LIMITED, so junk tokens cannot drive a siteverify call per request (security review of this change). The two reads, the status and one job's output, stay anonymous and are not locked to the origin: they queue nothing, and a job is readable only by the random id its submission returned, for a day. - The client identifier is the one #738 already uses. The per-client
bound counts the Cloudflare-verified, salted hash of
CF-Connecting-IP, trusted only on a request carrying the origin secret, the identity every anonymous route shares. Turnstile is what makes that count mean a person at a browser rather than a script rotating addresses. - Three switches, each failing closed.
labs_public_submission_enabled(defaulttrue, the owner's decision in code, reviewed in the pull request and read in the plan) setsLABS_PUBLIC_SUBMISSION_ENABLED; the Key Vault secretTURNSTILE-SECRET-KEY, read through theTURNSTILE_SECRET_KEYreference, must resolve; and an agent must be online. The status read says which is missing:PUBLIC_SUBMISSION_CLOSED,TURNSTILE_NOT_CONFIGUREDorLAB_AGENT_OFFLINE. A build of the site without the public site keyVITE_TURNSTILE_SITE_KEYkeeps the button disabled and says so. Setting the variable tofalsein thehcw-azureworkspace is the one-step kill switch. - The widget is created in the Cloudflare dashboard, not by Terraform.
The pinned provider has
cloudflare_turnstile_widget, whosesecretis a read-only attribute: managing it would put the secret key in HCP Terraform state, which Variables and secrets forbids, and it needs an account id and an account-level Turnstile permission the zone-scopedcloudflare_api_tokendoes not carry. The site key goes to the repository variable, the secret key to Key Vault through the API-keys page, as every other secret does.infra/frontend.tfrecords this beside the Cloudflare resources, and a test fails if the resource appears.
Consequences of this amendment:
- The CSP grants
https://challenges.cloudflare.cominscript-srcandframe-src, site-wide, and nowhere else. Static Web Apps sends one policy for every page, so the grant is not scoped to the builder; the script is. Only the builder loads it, and only while the lab is open, so every other page makes no request to Cloudflare.connect-srcis unchanged.frontend/src/lib/csp.test.jsholds exactly this. - Cloudflare sees the builder's visitors. Turnstile runs Cloudflare's
browser challenge on the page and receives the visitor's address from
siteverify. The site already sits behind Cloudflare's proxy, so no new
party receives the address, but the challenge is new processing, so the
site's privacy policy (
frontend/public/privacy-policy.html) says so and links Cloudflare's Turnstile Privacy Addendum. - Turnstile raises the cost of automation; it does not end it. A person
can solve challenges for someone else. The bounds are what cap the
damage: 2 an hour per verified address and 50 a day in total, against a
terraform-validatejob with no network, and decision 6 needs no change for that reason. The same bounds give one denial of service: about 50 bought tokens from 25 addresses can use up the day's global cap and pause the button for everyone until midnight UTC. That costs availability, not data, and a per-network sub-cap is the answer if it is seen (revisit triggers). - One more owner-held secret, one more public value. The secret key lives in Key Vault only and rotates by creating a new one in the dashboard and pasting it on the API-keys page. The site key rotates with it and needs a frontend deploy.
- This amendment answers the client-identifier question that the "public path stays closed" consequence below left for the revision.
Amendment 2026-09-28: Coder in the site's panes¶
Context. Decision 4 said the site never embeds Coder, and "Embedding
Coder in the site" is listed below as a rejected alternative. The owner's
decision of 2026-09-28, "The lab should only be accessible through 'panes'
from my site, lock to that", reverses both. #750 applied the lab-host half:
Caddy answers every top-level visit to a lab name, Coder's included, with a
302 to /education/labs, and lets only the site frame the lab
(Labs host, "Panes only"). From
then on the site's "Open in Coder" links led straight back to the labs page.
This amendment is the site half (#751).
Owner decision 2026-09-28. Embedding Coder in the site, rejected when this record was accepted, is now the chosen approach: a learner reaches Coder only in a pane on the site.
- Each lab has a page that holds its pane.
/education/labs/<id>(frontend/src/pages/shared/LabPanePage.jsx) frames the lab's workspace deep link,/templates/hcw-lab/workspace?mode=auto¶m.lab=<id>, and the cards on/education/labslink there. Its toolbar makes the pane itself full screen and goes back to the labs page. Nothing on the site links to Coder at the top level. frame-srcgains exactly Coder's two names,https://coder.lab.hybridcloudworks.comandhttps://*.coder.lab.hybridcloudworks.com(the workspace apps, where code-server runs).connect-srcis unchanged. The rejected alternative assumed the site would have to open it, and it does not, because the site still reads Coder's status through the Function App (decision 4).frontend/src/lib/csp.test.jsholds both.- The frame gets what code-server needs and nothing more:
sandbox="allow-scripts allow-same-origin allow-forms allow-popups", andallowfor clipboard read, clipboard write and fullscreen, granted to those two origins by name. Scripts plus same-origin would let a frame of the page's own origin lift its sandbox; the pane is always another origin. There is no top-navigation flag, so the pane cannot navigate the site, andframe-srcstops it navigating anywhere but Coder's names. - GitHub sign-in runs in a tab of its own. GitHub refuses to be
framed. The pane page's "Sign in with GitHub" opens, in a new tab, the
one Coder path #750 lets through at the top level,
/api/v2/users/oauth2/github/callback. Coder v2.37.3 starts sign-in there when the request carries nocode: it sets its state cookie and redirects to GitHub. After GitHub, Coder sets its session and redirects to a path of its own; that is a top-level visit, which #750 sends to/education/labs, and that page sends the tab on to the lab's pane. The first tab hears of it through astorageevent and reloads its pane. The site holds no credential: it records in localStorage only that a sign-in was started and that one finished. The session is Coder's cookie on its own name, which reaches the pane because the site and the lab are one site (the same registrable domain), so it is not a third-party cookie. The page comment inLabPanePage.jsxhas every step and its source. - The pane opens only when the status read says Coder is reachable. A
frame from another origin cannot tell the page what it shows, so the page
asks
GET /api/public/labs/coder-status. Anything but configured and reachable, or a frame that has not loaded in 30 seconds, shows "Lab workspaces aren't available right now." and never the error.
Consequences of this amendment:
- Two trust boundaries share a browser window, not an origin. The frame is sandboxed and cross-origin, and Coder's GitHub sign-in stays the access control; #750's lock is a browsing rule.
- The pane depends on the status read. Until
CODER_URLandCODER_STATUS_TOKENresolve in the Function App, every pane says the workspaces are unavailable, even when Coder is up. - A workspace app that Coder opens in a new window or tab leaves the
pane. Found while building #751, from Coder v2.37.3's dashboard source:
an app opens with
window.openwhen itsopen_inisslim-window, the code-server module's default, or in a new tab when it istab. Both are top-level visits, which #750 sends to the labs page. So in the pane a learner can sign in and create the workspace, and code-server's button opens nothing they can use. Opening code-server inside the pane is a lab-host change and is not made here (revisit triggers). - The site cannot tell whether a visitor is signed in, because it cannot read the pane. The page shows the sign-in step until this browser has come back from one (for Coder's default 24-hour session), offers "I've already signed in", and keeps the sign-in button on the toolbar for an expired session. A visitor GitHub signs in but Coder refuses (outside the organisation) also lands back on the pane, which shows Coder's sign-in page again.
Note, 2026-09-28: the lab launcher closes the code-server blocker. The
third consequence above, code-server opening nothing a learner can use, is
resolved without loosening #750's lock. The pane now loads a small static
page on Coder's own name, https://coder.lab.hybridcloudworks.com/_hcw/lab/?lab=<id>
(lab-host/coder/launcher/, served by Caddy with a strict policy of its
own, Cache-Control: no-store and no top-level exemption, so a direct
visit still goes to /education/labs). With the learner's own Coder
session, GET only and same origin, it reads their workspace for that lab,
named from a fixed map the site's catalogue shares (lab-lzb, lab-tfv,
lab-asc). It shows Coder's own create page, with Coder's consent dialog,
or Coder's own workspace page, where Start is, in a frame of its own when
the learner must act. When the build is running, the agent ready and
code-server healthy, it checks that code-server's subdomain_name is
exactly the name Coder builds for that workspace and the signed-in
learner, and replaces itself with that name under a fixed suffix. code-server stays on its own
origin, and path apps stay off (CODER_DISABLE_PATH_APPS, #760).
frame-src, the sandbox and allow are unchanged. The launcher posts its
state to the site, which accepts it only from Coder's origin and the pane's
own window, and shows it on the toolbar. On the same day the pane stopped
depending on the status token: point 5's status read now says configured
with CODER_URL alone and reachable from Coder's unauthenticated
/api/v2/buildinfo, and the token adds only the card's templates and
running count, so the second consequence above no longer holds. Rejected on
the way (research against Coder v2.37.3): POSTs with a scraped CSRF token
(undocumented, and it skips Coder's consent), a launcher served as a path
app (a workspace's JavaScript on the dashboard's origin), rewriting Coder's
HTML in Caddy, and exempting same-site top-level visits (which would end
panes-only).
Amendment 2026-09-29: Vault auto-unseal through the Arc identity¶
Status: accepted by the owner on 2026-09-29, and live on the host. The
owner accepted the trade stated below, the switch file was written, and the
owner ran the migration with three of the five keys. A restart then came back
unsealed with no keys typed: vault status read Seal Type azurekeyvault,
Recovery Seal Type shamir and Sealed false, and the journal said
unsealed with stored key. The five Shamir keys are now recovery keys. The
cold copy taken before the switch is /root/vault-before-726.tgz on the
host. As built, it takes effect on a host only when the owner writes
/etc/ansible/facts.d/hcw_vault_seal.fact there and runs the migration in
the Labs host runbook,
and doing that is the acceptance of the trade stated below. Until then Vault
stays on the five Shamir keys, exactly as the amendment of 2026-09-26 left it.
Context. That amendment's item 3 leaves Vault sealed after every restart,
the 04:30 unattended-upgrades reboot included, until the owner enters three
keys, and names #726 as the way out. #726 asked first whether Vault's
azurekeyvault seal can sign in through the Arc agent's local identity
endpoint, or only through Azure VM IMDS, a client secret or workload
identity. Only the first keeps a secret off the host, and without it this
record would not take auto-unseal.
The answer, read against the source of the pinned release, Vault 2.1.1.
go version -m on the signature-checked binary lists exactly the module
versions below.
- The seal (
go-kms-wrapping/wrappers/azurekeyvaultv2.0.14) signs in with a client secret only whentenant_id,client_idandclient_secretare all set, and otherwise callsazidentity.NewDefaultAzureCredential. Verified in source. - That chain (
azidentityv1.13.1) includesManagedIdentityCredential, which hands managed identity to MSAL for Go v1.6.0. MSAL recognises an Arc machine fromIDENTITY_ENDPOINTandIMDS_ENDPOINT, or from/opt/azcmagent/bin/himdsexisting, and runs the agent's challenge flow: a 401 naming a.keyfile in/var/opt/azcmagent/tokens, then the same request carrying that file's contents. The chain can be pinned to managed identity withAZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential. Verified in source. - Microsoft documents the rest of the host side: the agent sets both
variables in
/lib/systemd/system.conf.d/azcmagent.conf, its daemon ishimdsd.service, and "On Linux, you must be a member of thehimdsgroup" to read the challenge file. Verified on Microsoft Learn. - Arc has a system-assigned identity only. MSAL refuses a user-assigned one
there ("Azure Arc doesn't support user-assigned managed identities"), and
the seal turns a
client_idinto exactly that request, so the stanza carries none. Verified in source. - HashiCorp's seal page describes managed identity only "if Vault is hosted on Azure" and does not mention Arc. The path is supported by the code, not documented by HashiCorp: a Vault upgrade is a reason to read this again (revisit triggers).
- End to end, in a container: the real 2.1.1 binary, running as
vaultwith only thehimdsgroup added by its unit, answered the challenge of a stand-in for the agent, received a token, read the key from a stand-in for Key Vault, wrapped and unwrapped with it, migrated from Shamir and came back unsealed after a restart. That the real agent and Key Vault behave as the stand-ins do, which follow Microsoft's documented flow, is inferred; the owner's migration on the host is its proof.
Decision, accepted 2026-09-29.
- A lab-only vault, the Key Vault
kv-labhybrid-prod-cus-01inrg-lab-hybrid-prod-cus(infra/lab-hybrid.tf). Standard tier, RBAC authorisation, purge protection,prevent_destroy, and AuditEvent to the Management workspace. The name drops a hyphen because the pattern'skv-lab-hybrid-prod-cus-01is 25 characters and Key Vault allows 24. - One key,
vault-seal: RSA 3072, software-protected, allowedwrapKeyandunwrapKeyonly, withprevent_destroy. It is created through Resource Manager, which needs onlyMicrosoft.KeyVault/vaults/keys/write(Contributor has it), so the Terraform run identity holds no data-plane role on any vault. - The Arc identity's only grant: Key Vault Crypto Service Encryption User on that key, whose data actions are read, wrap and unwrap, and nothing on the vault or anywhere else. Terraform reads the Arc machine's principal at plan time and plans no grant while the machine does not exist, so a rebuilt host's new identity gets the grant at the next apply and the old one loses it.
- On the host, opt-in per installation. The
vaultrole adds aseal "azurekeyvault"stanza with noclient_idand no secret, and the unit pins the credential chain to managed identity, joinshimds, starts after the agent and retries every 30 seconds without a limit. The role reads the key as the Arc identity before it writes the stanza, and refuses to write a configuration without it while Vault's data is under it. - Cost. No monthly charge for a Standard vault or a software key; RSA 3072 is an advanced key type at $0.15 per 10,000 operations (Azure Retail Prices API, Central US, read 2026-09-29). Vault's seal health check wraps and unwraps every 10 minutes while unsealed, about 8,800 operations a month, so about $0.13 a month. AuditEvent adds about 300 rows a day to the Management workspace, under a megabyte (estimated, not measured), inside its free 5 GB a month and well under 1% of its 0.25 GB daily cap.
The trade, which is the owner's to accept. With Shamir, root on this host, which a workspace escape can reach, cannot unseal Vault: the root key is split across keys only the owner holds. With auto-unseal, root on the host together with the Arc identity can. Root can restart Vault and it comes back unsealed, and a copy of the disk taken off the host, which holds the agent's identity key as well as Vault's storage (the agent keeps that key on the host unless the machine uses a TPM key store), can be unsealed for as long as that identity keeps its grant. With Shamir, the same copy stays sealed. What root gains that it does not already have is therefore narrow: root can already read an unsealed Vault's memory, which the amendment of 2026-09-26 accepted, and Vault is unsealed nearly all the time. The difference is the sealed window after a restart, and a disk copied away. The boundary that makes this acceptable is unchanged: Vault holds lab-host secrets only, never a production secret and nothing that exists nowhere else, and the Arc identity reaches one key and nothing else. The detection is the vault's audit log, where every unwrap carries the caller's IP address. The revocation is removing the grant, or disconnecting the machine, which deletes the identity. The price in availability is that Vault needs Entra ID, Key Vault and the agent to start, and the recovery keys cannot stand in for them, so an outage leaves Vault down until they answer, and a deleted key would lose the Vault for good.
Alternatives considered.
- A service principal and a client secret on the host. Rejected: a stored secret root can read, and one more thing to rotate. It is what #726 was opened to avoid.
- Workload identity federation. There is no token issuer on the host to federate, and Arc's identity is already the host's own credential.
- Key Vault Crypto User, or a grant on the vault. Rejected as wider than what Vault calls: Crypto User adds sign, verify, encrypt, decrypt, update and backup, and a vault-scope grant reaches any key added later.
- An IP rule on the vault's firewall. Not now. The host's address belongs
to the
hcw-labworkspace, whichinfra/never reads (decision 1), and a rule that drifted from it would leave Vault unable to unseal. Revisit trigger below. - A Premium vault and an HSM-backed key. A monthly charge per key for a Vault that holds only secrets their issuers can issue again.
- Keep Shamir. The default, and what the host runs until the owner writes the fact.
Consequences of this amendment, once the owner accepts it:
- A restart, including the 04:30 reboot, comes back
Sealed falsewith no owner step, and the five keys become recovery keys: still needed forgenerate-root, a rekey and a migration back, never again for an unseal. - Every
hcw-azureplan reads the Arc machine, and tolerates its absence. - A rebuilt host starts without the seal, whatever the repository says; the owner writes the fact again after the next apply has moved the grant.
- Once the seal is on,
bootstrap.shmust never run at a commit older than #726, whose role would write a configuration without it.
Amendment 2026-10-07: container-runtime privilege separation (LAB-5)¶
Status: decided 2026-10-07 under the estate review's finding LAB-5
(#951), and in effect on the host from the first bootstrap.sh run after
the merge, which the owner makes. The evidence the review asks for is the
owner's to collect on the host
(Labs host runbook).
Context. Decision 4 accepted that Docker daemon access is root on the
host, and the consequences bullet below names the two holders and the
revisit trigger: "moving workspaces and jobs to a rootless or separate
daemon". The estate review of 2026-10-06 rated that High (LAB-5). #900 put a
path allowlist in front of the Coder server and tightened the agent's unit,
and left three things open: the agent's user was still in the docker
group; no container ran in a user namespace, so a container escape was root
on the host; and a compromised Coder server could still create a privileged
container through an allowed endpoint, because the proxy reads paths, not
bodies.
Decision.
- The agent leaves the docker group and reaches the host daemon through
its own socket proxy.
hcw-labs-agent-docker-proxyis HAProxy 3.4.6 (the newest LTS line) from Docker's official image, pinned by digest, with its configuration in this repository (lab-host/ansible/roles/labs_agent/templates/docker-proxy.haproxy.cfg.j2). It listens on a Unix socket ownedroot:hcw-labs-agentwith mode 0660, in a container with no network, a read-only root filesystem and every capability dropped, and the unit'sDOCKER_HOSTnames it. Its allowlist is the exact callsdocker run --rmanddocker rm -fmake for a job, read from Docker CLI 29.8.1, the host's pin, against a recording daemon (scripts/fixtures/docker-cli-agent-requests.json): ping, a create namedlabjob-<12 hex>, attach, wait and start by container id, and a forced remove of alabjob-name. No pull (lab_imagespulls as root first), no exec, inspect, list, volume, network, image or system call.
A path allowlist does not read bodies, and the create body is where a
privileged container is asked for. So the proxy reads that body too and
refuses a create that carries privileged mode, a host or shared
namespace, the opt-out of the daemon's user namespaces (UsernsMode), an
added capability, a device, a mount, a bind other than the job's own
staging directory read-only, unmasked kernel paths, a security option
other than no-new-privileges, a network other than none, a sysctl,
another runtime or log driver, or an image that is not a digest. These
are deny-anywhere regular expressions, and that is sound only because of
two facts the proxy also enforces: the agent's bodies are ASCII JSON with
no backslash in them (the CLI escapes nothing in them), and with any
other byte and every backslash refused, a key cannot be spelled through
a JSON escape or a character Go's decoder folds to ASCII (ſ to s,
K to k). A rule that matches a key anywhere then matches it wherever
Docker would read it, in any case and however often it is repeated.
scripts/lab-host-docker-proxy.test.mjs evaluates the file's own rules
against every recorded request, against creates that would turn a job
container into a way out, and against calls a job never makes; CI parses
the rendered file with haproxy -c at the pinned image.
Why a proxy and not a rootless daemon for the agent, which the review
also offered: the job containers stay on the host daemon, which item 2
puts in a user namespace anyway, so a rootless daemon for the agent would
add a third daemon and image store to contain what the remap already
contains, while the proxy also takes away everything a job does not
need. And the agent's unit has ProtectHome=true, which hides
/run/user, where a rootless daemon's socket lives.
- User namespaces on the host daemon.
/etc/docker/daemon.jsonsetsuserns-remap: default: every container's root is the first id of thedockremapuser's subordinate range on the host, and its uid N that id plus N. The job containers, Coder's server and PostgreSQL run that way. It needs the containerd image store off, because that store "is not available when using user namespace remapping" (Docker, read 2026-10-07; moby#47377), so the daemon goes back to the overlay2 graph driver, anddocker inforeportsname=usernsin its security options.
Three of this host's own containers opt out with --userns=host, and
nothing else can: the two socket proxies and Portainer, which must open
a Docker socket that a remapped root cannot. The Caddy builder also runs
that way, once, to write the binary into a root-owned directory. None is
created through a proxy: they are created by root (Ansible and Compose), and the agent's
proxy refuses any create that sets UsernsMode. Coder's server never
reaches this daemon at all (item 3).
Turning the remap on gives the daemon a new data root and hides the old
one. So the run that switches stops every running container first,
restarts the daemon before any later role creates a container, and
copies two volumes into the new root once: Coder's PostgreSQL cluster
(shifted into the remapped range) and Portainer's database (as it is,
since Portainer runs with --userns=host). The old root is only read,
and -e docker_userns_remap=false is the way back through the same
stop-and-restart. lab-host/ansible/roles/docker/tests/hcw-docker-volume-carry.test.sh
runs the copy as it ships against a tree shaped like PostgreSQL's.
- Coder's server reaches a separate, rootless daemon, never the host's.
The
coder_sandboxrole runsdockerd-rootless.sh(fromdocker-ce-rootless-extras, the same build and pin asdocker-ce) as the unprivileged system userhcw-coder-docker, from a systemd user unit kept up by lingering, with its own subordinate id range and the cpu, memory and pids controllers delegated so the template's limits hold.coder-docker-proxymounts that daemon's socket directory instead of the host's socket, and every workspace runs there. The proxy still reads paths only. What changed is what it reaches: a privileged container with/bound, asked for by a compromised server or a bad template, is privileged insidehcw-coder-docker's user namespace, and/is what that user may see.docker infoon that daemon reportsname=rootless, which is user namespaces by construction, the daemon's own included.
The review offered an authorisation plugin instead. It was the weaker
choice here, for four reasons. A plugin sits in the host daemon and
cannot tell Coder's requests from root's own, and root's own include the
containers that must bind a socket, so its policy would have to trust a
caller marker. A policy that denies by field is only as good as its list
and has to parse JSON exactly as Docker does. A plugin that is down stops
the daemon answering anyone. And the published managed plugin,
openpolicyagent/opa-docker-authz-v2, was last pushed at 0.9 in January
2024, behind its source releases (0.11 on 2026-10-06). A rootless daemon
needs no policy, comes from a repository and pin the host already trusts,
and is verified by one line of docker info.
What this does not do.
- It does not make the regular expressions a parser. They cannot
require a setting to be present: a regular expression cannot tell
HostConfigfrom an unknown key that Docker ignores. So networknone, the read-only root,cap-drop ALLand uid 65534 remain the runner's own contract, asserted byvps-agent/lib/docker-runner.test.js, and with the remap they are containment of a job, not of the host. A future CLI that escapes a character in a body would be refused rather than passed; the fixture test and the end-of-run check fail first. - It does not separate jobs from the host daemon's other containers by id. Attach, wait and start take any 64-hex container id, because the CLI uses the id the create returned. A compromised agent could start a stopped container of the host's own or read the output of a running one; it cannot inspect one (no environment), exec into one, or create one outside the rules.
- It does not isolate learners from Coder's server. Coder can still do anything on the sandbox daemon to any workspace, as before; the change is that it cannot do anything as root on the host.
- It does not stop a kernel exploit. A process that escapes a remapped
or rootless container is an unprivileged uid that still shares the
kernel. Unprivileged user namespaces stay restricted by Ubuntu's AppArmor
for everything but
rootlesskit. - Portainer is still root. It holds the host's socket by design, for the owner only, on the loopback (amendment of 2026-09-26).
Consequences of this amendment.
- Two daemons and two image stores.
lab_imagesplans each: the job images on the host daemon, the workspace image (about 484 MB) on the sandbox's; since #1268, every template's workspace image, nothcw-lab's alone. Portainer sees the host daemon only. - On the run that turns it on, running jobs fail, workspaces stop, every image is pulled again, and a workspace starts again with an empty home volume, because its old volume belongs to the host daemon's old data root (kept on disk, served by nothing).
bootstrap.shchecks all of it at the end of every run (privilege_checks): the agent in no docker group,name=userns, the agent proxy's socket and container, one job run as the agent user through the proxy whiledocker psand a privilegeddocker runare refused, Coder's proxy carrying the Compose file's policy with the sandbox socket as its one mount, andname=rootlessbehind it.- The cpu, cpuset and io controllers are delegated to every user manager on
the host, not only
hcw-coder-docker's. That widens what a logged-in user may limit for their own processes and nothing else.
Alternatives considered, for this amendment.
- Rootless Docker for the agent's jobs. Rejected for the reasons in item 1: the remap already contains the jobs, and the proxy narrows the agent further than any daemon would.
- An authorisation plugin on the host daemon for the agent and Coder both. Rejected for the reasons in item 3.
- A second rootful daemon for Coder. Rejected: a privileged container on any rootful daemon is root on the host, so it separates nothing that matters.
- Rootless daemons for everything, and no remap. Rejected: the host's
own containers (the Coder server and PostgreSQL among them) would stay
without a user namespace, and the review's acceptance reads
docker infoon the host daemon. - A sandboxing runtime (gVisor, Kata, Sysbox) for jobs and workspaces. Not taken now: a new runtime to pin, patch and explain for one host, when user namespaces answer the finding. It is the next step if a kernel escape becomes the risk to plan for.
Revisit when:
- Docker's containerd image store supports
userns-remap(moby#47377), which lets the host daemon go back to it; - the docker CLI pin moves, which needs the fixture recorded again
(
scripts/lab/capture-docker-cli-requests.mjs; the test fails until it is); - a workspace needs something a rootless daemon cannot give it (nested Docker, a device), which reopens item 3 and points at Sysbox;
- the agent needs a Docker call the proxy refuses, which is a change to the allowlist with its own recording, never a wider rule.
Amendment 2026-10-14: off-host backups and a rehearsed rebuild (LAB-9)¶
Estate review 2026-10-06, finding LAB-9 (#955): the host's only backups were seven days of Coder PostgreSQL dumps on its own disk, Vault's raft store had none, and the reinstall runbook had never been rehearsed or timed. The amendment of 2026-09-26 above says the host is rebuilt rather than repaired; this one says what the rebuild restores from.
Decision. Owner, 2026-10-14, choosing among Azure Blob Storage written by the Arc identity, the VPS provider's object storage, and local-only with a rehearsal: Azure Blob Storage.
- Where. One storage account in the lab's resource group,
rg-lab-hybrid-prod-cus(infra/lab-hybrid-backups.tf): Standard GRS, so the copy is off the VPS and off its region; Cool tier; TLS 1.2; shared keys off and OAuth the default, so the only principal that can write is the one granted; the public endpoint reachable, for the reason the lab Key Vault's is — the writer is a VPS outside Azure with no private path, and Entra plus one container-scoped grant gate every call. One container,host-backups, under a 35-day time-based immutability policy (unlocked): the host can write a blob and cannot delete or overwrite one inside the window, so a host that is compromised, and can therefore write backups, cannot destroy them. Versioning and 35-day soft delete behind that. - Who writes. The Arc machine's system-assigned identity — the one Vault's
seal signs in with — gets its second grant, Storage Blob Data Contributor
on that container only. No credential is minted or stored on the host for
this: it asks the Arc agent on
127.0.0.1:40342for a token forhttps://storage.azure.com/, the challenge flow the vault role already performs for the seal key. - What is shipped. The nightly Coder dump (
coder-postgres-backup, 03:30 UTC) is uploaded after it is written, tohost-backups/coder/; a nightlyvault operator raft snapshot saveis uploaded tohost-backups/vault/. The snapshot needs a Vault token with a raft-snapshot policy: seeded once withhcw-vault-backup-seed(the owner's Vault token on stdin; a periodic orphan token stored root-only) and renewed by the unit itself, the Coder rotation credential's pattern (one seed, then automatic; owner instruction 2026-10-09). Both units keepOnFailure=hcw-unit-failed@, and an alert per kind fires when no blob was written in two days: a missing backup is a row, not a silence. - Not encrypted client-side before upload. The account is Entra-only, immutable, in the lab subscription, encrypted at rest with Microsoft-managed keys, and its logs name every caller. A client-side key would be one more thing a rebuild must recover before it can restore anything, which is the failure this amendment exists to shorten. Revisit if the Coder database ever holds more than lab metadata and users' OAuth links.
- The rebuild. The reinstall runbook gains a restore step: a helper
lists the container through the Arc identity, downloads the newest (or a
named) dump and snapshot, restores the database into the fresh
coder-postgresand the raft snapshot into the re-initialised Vault (-force; the seal key in Key Vault is unchanged, so the restored data unseals). The runbook carries a Last rehearsed / elapsed line; the rehearsal is the owner's, on a fresh VPS, and this finding's acceptance is that line, a dated blob in the container, and the failure row.
Consequences. The Arc identity holds two grants instead of one, both
scoped to a single resource. A few megabytes a night in GRS Cool storage.
verify-alert-state.yml picks up the two new rules from the declarations.
What this does not change: Vault still holds lab-host secrets only, and the
host is still rebuilt rather than repaired — the restore is what a rebuild
ends with, not an alternative to it.
Validation. (1) infra/lab-hybrid-backups.tf applied, the output
lab_hybrid_backups_storage_account naming the account. (2) After the host
pull request: a blob under host-backups/coder/ and one under
host-backups/vault/ dated the night after the host's run, readable in the
portal, and a delete attempt on one refused with the immutability policy
named. (3) The alert's fire test: stop the Coder backup timer for two nights
Amendment 2026-10-17: visitor lab sessions outside the organization (#1016)¶
Context. Under Decision 4, Coder is restricted to the HybridCloudWorks GitHub organisation (CODER_OAUTH2_GITHUB_ALLOWED_ORGS: [HybridCloudWorks]), and sign-ups remain off (CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=false). However, visitors outside the organisation who wish to complete interactive walkthroughs without GitHub organisation membership could not access workspaces. Coder Community edition lacks enterprise per-user quotas, auto-stop enforcement, and dormancy. Opening sign-up would permit any visitor to exhaust the host's 5 workspace slots, violating Decision 4.
Decision, accepted 2026-10-08 and 2026-10-16 (#1016).
1. Brokered sessions: The site brokers ephemeral Coder workspaces on behalf of visitors. Visitors never receive persistent Coder accounts. The Function App acts as broker using a dedicated Coder broker token held in Key Vault (CODER-BROKER-TOKEN).
2. Access & Verification: Requests require the site's exact Origin and a Cloudflare Turnstile token for the visitor-session action (functions/src/lib/labs/public-lock.js).
3. Capacity & Rate Limits:
- Global concurrency cap (LABS_VISITOR_MAX_CONCURRENT, default 2), reserving remaining slots for team members.
- Per-client daily limit (2 sessions per client key).
- Hard session duration: 60 minutes (VISITOR_SESSION_DURATION_MS).
- Hard auto-reap: sessions past expiresAt are automatically reaped and deleted upon subsequent queries/requests (a session is marked over only once Coder has confirmed the clean-up, and one it has not confirmed is retried a bounded number of times, #1388), with an emergency shutoff switch (LABS_VISITOR_SESSIONS_ENABLED=false).
4. Hygiene: Visitor workspaces sparse-clone public repositories with no secrets mounted, respecting #954 egress isolation (LAB-8).
Amended 2026-10-26 (#1403, item 1; the owner answered timer). Decision 3's auto-reap no longer waits for a visitor. A timer, sweepVisitorSessions, runs the same sweep every two minutes (the retry step is two minutes, so each retry and each takeover of a dead claim lands within one tick of being due), whether or not anyone visits and whatever LABS_VISITOR_SESSIONS_ENABLED says: that switch closes the door to new visitors, and turning it off while sessions exist must not strand their Coder users and workspaces. The timer is off until the owner adds SWEEP_VISITOR_SESSIONS to enabled_timers; it never creates a session or a Coder user; it does nothing when the broker token or Coder's address is not set; and it spends clean-up tries on the clock, so a Coder outage of more than about twenty to thirty minutes leaves the waiting sessions as cleanup_failed, for a person to finish (runbook, "The sweep timer"). Decision 1's token is CODER-BROKER-TOKEN in Key Vault. Terraform declares its reference since pull request #1422, for #1403 (infra/functionapp.tf, recorded in docs/standards/required-inputs.md), and that apply is safe before the secret exists: the broker then reads the token as not set. What stays with the owner is deciding which Coder user and permissions the token is made with, minting it and seeding it (runbook, "Seeding the broker token").
Amended 2026-11-03 (#1400 and #1403 closed). Decision 2's door, LABS_VISITOR_SESSIONS_ENABLED, is declared in Terraform from labs_visitor_sessions_enabled (false by default; it had been read by the code and declared by nothing since 2026-10-17, so a copy set by hand would not have survived an apply). The broker's calls to Coder follow no redirect, write into a Coder path only a UUID, a v- name of the broker's own shape or one of the labs' workspace names, read a user back by its id before deleting it, bound the three lab_jobs queries with TOP, and the sweep stops taking up sessions 40 seconds before the claims it makes could run out. A session still belongs to the visitor's hashed address (#1400, item 4): the owner accepted that for now, because the lab is free, time-boxed to an hour and holds nothing of the visitor's; a per-session secret is the next step if that changes. The live check of item 5 (the origin lock on the deployed app and the Cloudflare rule) stays the owner's before the door is opened (runbook, "Visitor lab sessions").
Consequences and accepted risks¶
- Two Terraform workspaces, two lifecycles. A change to the lab host is a
run in
hcw-labthat the owner confirms in the HCP Terraform UI, the same way ashcw-azure. The isolation is the point, and it means the two can drift in provider versions and conventions; the IaC repository standard applies to both. - No Kubernetes means no Kubernetes lab. Followers who want AKS or k3s content will not get it from this host. Docker Compose is the whole orchestration story, and the host is rebuilt rather than repaired.
- Arc adds an identity to the host. The Arc agent's system-assigned
identity can be granted Azure roles. This record grants it none beyond what
the data collection rule needs; any grant is a change to
infra/with its own review. (The amendment of 2026-09-29 made the first, ininfra/lab-hybrid.tf: read, wrap and unwrap on one key in a lab-only Key Vault, neverkv-site-prod-cus-01.)
Amendment 2026-10-07 (estate review, LAB-6; owner decision that day):
the Connected Machine agent is locked down on the host, by the arc
role (lab-host/ansible/roles/arc/tasks/onboard.yml). Since #900 and
#909, applied by bootstrap.sh on 2026-10-07 (failed=0), its extension
allowlist admits the Azure Monitor Linux agent alone
(extensions.allowlist) and incoming connections, SSH over Arc and
Windows Admin Center, are off (incomingconnections.enabled false). A
Contributor on rg-lab-hybrid-prod-cus can therefore no longer push an
extension or a Run Command, which ran as root. These settings live on the
host and cannot be changed from Azure. Guest configuration is off as
well (guestconfiguration.enabled false, set by the same role the same
way). The review recommended enabling it; the owner chose off. Locking the
agent down was the point of #900, and Microsoft's guidance for a
locked-down machine disables guest configuration so that a custom machine
configuration policy, which runs privileged on the host, cannot change
the agent's configuration
(Extensions security for Azure Arc-enabled servers,
"Locked down machine best practices", read 2026-10-07). It also avoids
the machine-configuration charge for an Arc server: the Azure Arc pricing
page, read 2026-10-07, lists Azure Policy guest configuration at USD 6 per
server per month (USD 0.009 per server-hour), included only with
Defender for Servers Plan 2, which stays off. What it costs: the
audit-only machine configuration in decision 3 has no agent on the host
to run it. The audit-linux-baseline-lab-hybrid assignment
(infra/lab-hybrid.tf, behind lab_hybrid_policy_enabled) cannot produce
a compliance result for this machine while the setting is off, so the
labs page's compliance reading would have nothing current to show.
Whether to keep that switch, remove the assignment and the
Microsoft.GuestConfiguration provider registration, or revisit this
choice is an open owner question on #952. The evidence for all three
settings is sudo /opt/azcmagent/bin/azcmagent config list on the host,
which stays with the owner.
Amendment 2026-10-16 (#953, LAB-7; owner decision that day): the owner
reaches the host through Azure Arc SSH, and port 22 then closes to the
internet. LAB-6's "incoming connections off" is partly reversed, for
port 22 only, to carry it: the arc role sets
incomingconnections.enabled true and incomingconnections.ports 22
(arc_incoming_ssh_enabled, default true; false puts LAB-6's state back),
and infra/lab-hybrid.tf declares the machine's default connectivity
endpoint with one SSH service configuration, port 22. Windows Admin Center
and every other port stay refused. The extension allowlist is
unchanged, the Azure Monitor Linux agent alone, and the role fails
closed if it reads back anything else after setting the incoming ports.
Entra ID login over SSH (the AADSSHLoginForLinux extension) does not
support Ubuntu 26.04, so no login extension is added: the login is the
local user hcwadmin with the one pinned key, as it is directly. Arc
SSH is a transport in front of sshd's own key check, not a login. The
relay delivers a stream to sshd on the host's loopback; an identity that
may list the endpoint's credentials (Owner or Contributor on the machine
or its group) can open that stream, and still meets sshd asking for the
pinned key, as a connection from the internet does today. No role is
granted for it: the owner is Owner on the subscription. Break-glass is
the Hostinger web console (hPanel's Web Console), which depends on
neither Arc, the relay nor port 22. The order is staged: the keys,
AllowUsers and alert-lab-ssh-login land while port 22 is open; then
this; then the owner connects through Arc
(scripts/lab/Connect-Lab.ps1 -Arc) and signs in at the web console; only
then does a later change close port 22 and set lab_ssh_loopback_only,
from which an accepted login from anywhere but loopback pages.
(SSH access to Azure Arc-enabled servers,
read 2026-10-16.) (2026-10-17, #953 stage 3: both preconditions were met
that day, so port 22 closes at ufw and lab_ssh_loopback_only is on. The
hardening role closes 22 only while the Arc agent is Connected with
incoming connections on for port 22 (hardening_ssh_arc_gate), and leaves
it as it is while the agent is Disconnected, because a rebuilt or reset
host is onboarded over port 22 by Register-LabArc.ps1. Since #1257 sshd
also listens on the loopback alone, and that bind follows the same gate.)
- Ingestion is bounded but not zero. Heartbeat and auth syslog on one host
are kilobytes a day against the workspace's 0.25 GB/day cap
(ADR 0031 records the headroom).
A chatty authpriv source under an SSH brute-force attempt is the case to
watch, and the daily-cap alert already exists.
- Without Defender for Servers there is no managed EDR on the host. The
controls are SSH keys only, an inbound policy of 22, 80 and 443 (80 and
443 since #953 stage 3, with SSH through Azure Arc), audit-only
machine configuration, and the auth syslog above. This is a stated cost
trade, revisited in the triggers below.
- Coder is a second identity system with its own users. Learners have a
GitHub-backed Coder account and nothing on the site. Coder's own hardening —
workspace resource limits, template review, upgrade cadence — belongs to
#659 and is not covered here.
- Two processes can drive the Docker daemon, and daemon access is root on
the host. Accepted, with the blast radius kept small on purpose. (Three
once the owner turns Portainer on; the amendment of 2026-09-26 bounds the
third by who can reach it.) The
Coder server container holds the socket; the hcw-labs-agent service is in
the docker group. Whoever controls either controls the daemon, every
workspace and every job container, so --network none and the label
checks protect learners and jobs from each other, not the host from those
two. The mitigations are: the socket goes to the Coder server only, never
to a workspace (asserted by the #679 template test); workspaces run on
their own bridge network with no route to the Compose network; the Coder
server runs as a non-root user; the agent runs only the fixed argv
templates in vps-agent/lib/capabilities.js, never a shell string shaped
by a payload, with the payload bind-mounted read-only, and the API
authorises every claim against the agent's registry document; and the host
deliberately holds no data of record and no Azure or application
credential: only the agent's certificate, which reaches three API
endpoints, and Caddy's DNS token, which is scoped as narrowly as Cloudflare
allows and which, in the interim the next bullet records, can edit
production DNS records. That token is the one production-reaching
credential on the host, and it is why the dedicated lab zone is the
target shape. A compromise therefore costs a rebuild, plus a check of the
production zone's records until that zone exists, not data. Moving workspaces and
jobs to a rootless or separate daemon is a revisit trigger, not a
prerequisite.
Amendment 2026-10-06 (estate review, LAB-5): the Coder server no longer
holds the socket. A coder-docker-proxy Compose service (HAProxy, one
allow rule per Docker API section) mounts it read-only on a control
network the server alone shares with it, and the server reaches the
daemon through it over DOCKER_HOST: containers, images, networks,
volumes and the lifecycle verbs the provisioner uses, and nothing else —
no exec, build, commit, swarm, system or auth. This narrows a Coder
compromise; it does not contain it. The proxy inspects paths, not
bodies, so a compromised server can still create a privileged container
or bind the host's / and start it, which is root as before. Containing
that needs a body-aware authorisation layer (a Docker authz plugin that
rejects privileged mode, host binds, host namespaces and added
capabilities) or a separate or rootless daemon for Coder, and that is the
revisit trigger, now with its shape written down. template.test.mjs
asserts the mount, every proxy policy key, the networks, and the absence
of the socket and the docker group from the server. The agent keeps its
docker group; its unit gained system-call and address-family filters and
its npm ci runs with --ignore-scripts.
Amendment 2026-10-07 (LAB-5), the revisit trigger taken: neither
process can drive the host daemon as root any more. The agent is in no
docker group and reaches the daemon through its own proxy, whose
allowlist is the calls a job makes and whose create rules refuse the ways
out; the daemon remaps user namespaces, so a job container's root is an
unprivileged uid; and the Coder server's proxy holds the socket of a
separate rootless daemon run by hcw-coder-docker, where every workspace
runs. The amendment of that date
records the choices and what they leave.
- Cloudflare API tokens are zone-scoped, and lab.hybridcloudworks.com is
a name in the production zone. Cloudflare cannot scope a token to one
record, so any token with DNS edit on hybridcloudworks.com can change the
site's own records. Two tokens exist and neither is on the host in a form
that reaches production DNS more than it must: the hcw-lab workspace
token creates the lab records and lives only in HCP Terraform; Caddy's
renewal token lives on the host in /etc/caddy/env, owner root, group
caddy, mode 0640, written from Ansible Vault and read by the non-root
caddy service through its unit's EnvironmentFile, so the service can
renew and nothing else on the host can read it. Caddy holds one certificate whose names are
lab.hybridcloudworks.com, *.lab.hybridcloudworks.com and
*.coder.lab.hybridcloudworks.com (Coder's workspace apps are one label
below coder.lab, and a wildcard covers one label only). The chosen shape
is DNS-01 by CNAME delegation: the two challenge names those SANs
resolve to, _acme-challenge.lab.hybridcloudworks.com (for the apex and
*.lab) and _acme-challenge.coder.lab.hybridcloudworks.com (for
*.coder.lab), are CNAMEs into a dedicated lab zone that holds no
production record, and Caddy's token is scoped to that zone alone. Until
the owner has that zone (a small annual spend, tracked on #661), Caddy's
token has DNS edit on the production zone, and that interim is an accepted
risk recorded here rather than a surprise. Corrected 2026-10-06 (estate
review, LAB-1): this bullet used to say the hcw-azure plan check and
the deploy-drift monitor would show any production record the token
altered. Neither does: the drift monitor reads GitHub only, the plan covers
the three records Terraform manages, and the apex and www records are
hand-made. The only record of a zone edit is Cloudflare's own audit log.
Until the lab zone exists, the compensating steps are the token's
client-IP filter and expiry in Cloudflare and an audit-log notification
for DNS changes on the production zone, both owner steps. Revised
2026-10-18 (#948): the client-IP filter and the 2027-01-04 expiry are
live (owner, 2026-10-07, read back 2026-10-18); the notification is not
possible, because Cloudflare has no notification type for audit-log or
DNS-record events on any plan, and a subdomain zone is Enterprise-only, so
the dedicated lab zone means a second registered domain, a purchase
decision for later. The interim is now a poller of the account audit log:
.github/workflows/cloudflare-dns-audit.yml runs
scripts/cloudflare-dns-audit.mjs daily over the window since its previous
successful scheduled run (at least 26 hours, at most 14 days), allows the
hcp-terraform-cloudflare-dns token's edits, the Caddy token's
_acme-challenge.*.lab TXT edits, the owner's own dashboard edits and
Cloudflare's own certificate-renewal TXT on the apex (since #1421), and
opens an issue on anything else; a run that cannot read the log fails
rather than passing quietly
(Edge and DNS verification, "The
daily DNS audit"). The audit-read token it needs is the owner step.
Cut-over begun 2026-10-18 (#948): the dedicated lab zone exists. It is
hybridcloudinsights.com, a domain the owner already held, connected to
the same Cloudflare account that day (Free plan, nameservers moved at
Hostinger, zone emptied); no public name changes, the zone only ever
holds the challenge TXT records. infra-lab/dns.tf adds the two
_acme-challenge CNAMEs to _acme-challenge.lab.hybridcloudinsights.com,
and roles/caddy renders dns_challenge_override_domain with that name,
because certmagic finds the zone for a challenge name by SOA walk and does
not follow a CNAME. Cut-over completed 2026-10-18, 21:04Z (#948): the
two CNAMEs applied by the hcw-lab workspace (whose VCS source had to be
repointed to the moved repository first; it had planned nothing since
2026-10-07); the runtime token re-issued with Zone:DNS:Edit and
Zone:Zone:Read on the lab zone alone (both, or the provider cannot find
the zone to write in), IP-allowed to the host, expiring 2027-10-17, and
rotated on the host; one forced issuance as the proof — Let's Encrypt
issued a new *.lab.hybridcloudworks.com certificate through the
delegation, with the stored certificate copied aside and the restore
line ready; the production-zone token revoked the same evening. The
interim this bullet describes is over: no credential on the lab host can
write to the production zone. The audit-log poller keeps watching that
zone regardless.
- lab-image/ is a new supply-chain surface. Every image it publishes
must be digest-pinned where consumed, and the existing
capabilities.test.js assertion that every capability names a digest is the
gate. The Terraform provider mirror is a large image layer, and its pins are
one more set to keep current.
- The public path stays closed, so the labs pages remain read-mostly until
a revision of this record. That is deliberate: the rate limits above need a
client identifier the anonymous site does not have yet, and choosing one
(edge-hashed IP, signed cookie, or a Coder session) is the revision's job.
(Revised 2026-09-28: the amendment of that date opens the path from the
builder's pane and chooses the edge-hashed IP, made meaningful by a
Turnstile token.)
Alternatives considered¶
- k3s on the host, with lab jobs as Kubernetes Jobs. Rejected by the owner
on 2026-09-24. It adds a control plane to run, patch and explain for one
node, and turns "a VPS with Docker" — the thing a follower already has — into
a distribution they must learn first. Docker's
--network none, pids, memory and CPU limits already give the sandbox the isolation the runner needs. - A self-hosted GitHub Actions runner on the VPS, for labs or for CI. Already rejected in ADR 0025: this repository is public, and a self-hosted runner on a public repository lets a fork pull request execute code on the host. ADR 0021 (number reused) records the Container Apps form of the same idea and why it was dropped.
- Managing the VPS by hand, or from Ansible alone. Rejected. Without a Terraform record the host's plan, OS and DNS live in a control panel that no pull request can review, which is the state it was in when this record was written.
- Putting the lab host in the
hcw-azureworkspace. Rejected. It would let a provider error or a mistaken destroy in a lab experiment surface in the production run queue, and it needs the Hostinger token in the workspace that holds the production Azure credential. - Site-hosted learner sign-in (a public Entra External ID or GitHub OAuth flow on the site itself). Rejected. ADR 0006 keeps the site's identity for administrators only, and Coder already has the learner sign-in the labs need.
- Embedding Coder in the site through an iframe. Rejected. It requires
opening
frame-srcandconnect-srcto the lab origin, joins the two trust boundaries in the browser, and gives the learner a worse editor than the one Coder serves directly. (Chosen 2026-09-28, owner decision, #750 and #751: see the amendment of that date. Onlyframe-srchad to open;connect-srcdid not.) - Defender for Servers Plan 1 on the Arc machine. Rejected for now on cost (a per-server monthly charge for one host with no data of record). See the revisit triggers.
- Opening public submission in this record, with the bounds above. Deferred to a revision, for the client-identifier reason given under consequences. (The revision is the amendment of 2026-09-28.)
- For that revision (2026-09-28), the origin alone. Rejected: any script
can send
Origin: https://hybridcloudworks.com, so it locks out other sites' pages and nothing else. - For that revision, a signed session cookie from the site. Rejected: the site has no anonymous session to sign, and minting one is a new token service to secure, rotate and explain, where Turnstile is a managed one the site's own proxy vendor already runs.
- For that revision, a Coder session. Rejected for the builder: it would make a learner sign in with GitHub to validate a download, and the builder is public by design. Coder stays the boundary for browser workspaces.
- For that revision, the widget as
cloudflare_turnstile_widgetininfra/. Rejected, for the state and token-scope reasons in item 6 of the amendment.
Validation and revisit triggers¶
- Validation:
hcw-labexists in thehcworganisation, is VCS-connected to this repository with working directoryinfra-lab/, has auto-apply off, and a plan there shows the VPS and DNS records. Thehcw-azureworkspace shows no change from the same commit.- After the owner applies, the host appears as Connected under Azure Arc
> Machines in
rg-lab-hybrid-prod-cus, and aHeartbeatquery in the Management workspace returns rows for it. - The control plane is checked by name, not by count:
docker pson the host shows the Compose servicescoder,coder-docker-proxyandcoder-postgres(and the containerportainerwhileportainer_enabledis true, and since the amendment of 2026-10-07 the agent'shcw-labs-agent-docker-proxy), andsystemctlshowscaddy,hcw-labs-agentandnode-exporteractive as host-native units (andvaultwhilevault_enabledis true). Every other container carries either the Coder workspace label (com.coder.resource=true) or thehcw.lab-joblabel the agent sets, and any container with neither is a finding. Since 2026-10-07 workspaces are listed on the sandbox daemon (docker -H unix:///run/hcw-coder-docker/docker.sock ps) and job containers on the host's. The count is not asserted, because a running workspace or job legitimately adds containers.which kubectl k3sreturns nothing. /education/labsshows the Arc status card fed by the Function App's Resource Graph read under its Reader grant onrg-lab-hybrid-prod-cus(decision 3), and shows an explicit absent state, not a fabricated one, when the resource group is missing or the host is down.enqueueLabJobwith noAuthorizationheader still answers 401, which the Health Hub labs probe already asserts.- Since the amendment of 2026-09-28:
GET /api/public/labs/submitanswersopen: truewhilevps-hostinger-01heartbeats, and aPOSTto it with noOriginheader answers403 ORIGIN_NOT_ALLOWED. The builder's Validate on the lab athttps://hybridcloudworks.com/tools/landing-zonequeues a job thatvps-hostinger-01claims and completes, and the page shows Terraform's output. - Every image
lab-image/publishes is referenced by digest invps-agent/lib/capabilities.jsand the Coder template. - Since the amendment "Coder in the site's panes": with Coder enabled, a
learner opens a lab from
/education/labs, signs in with GitHub once in the tab the pane opens, and comes back to the lab's page with the workspace in the pane; a direct visit tohttps://coder.lab.hybridcloudworks.comstill lands on/education/labs. Since the launcher note under it: after Coder's Confirm and Create, code-server opens inside the pane, and a direct visit tohttps://coder.lab.hybridcloudworks.com/_hcw/lab/also lands on/education/labs. - Since the amendment of 2026-10-07 (LAB-5): every
bootstrap.shrun ends with theprivilege_checksrole passing, and on the hostid hcw-labs-agentnames nodockergroup,sudo docker infolistsname=usernsamong its security options,sudo docker -H unix:///run/hcw-coder-docker/docker.sock infolistsname=rootless, andcoder-docker-proxycarries every policy key oflab-host/coder/docker-compose.ymlwith/run/hcw-coder-dockeras its one mount (the runbook's commands, with what good looks like). - Since the amendment of 2026-09-29, accepted and live that day: on the
host,
sudo systemctl restart vault && sleep 5 && vault statusshowsSeal Type azurekeyvaultandSealed false, andaz role assignment liston the keyvault-sealreturns one row, Key Vault Crypto Service Encryption User for the Arc machine's identity (the runbook's step 2). - Revisit when:
- Vault is upgraded past 2.1.x, which is a reason to read the seal's credential path again: signing in through the Arc agent is supported by the code, not documented by HashiCorp (amendment of 2026-09-29);
- the Key Vault audit log shows an unwrap from an address that is not the lab host's, which is a revocation first (remove the grant) and a review of the auto-unseal trade second;
- the lab host's public address becomes something
infra/can read, which reopens an IP rule onkv-labhybrid-prod-cus-01; - the owner decides to open public submission, which is a revision of §6 of this record and nothing else (done 2026-09-28, the amendment of that date);
- the daily cap fills from a handful of verified addresses, or siteverify passes tokens that the job pattern says were farmed, which reopens the lock (Turnstile's pre-clearance or interactive mode, or a signed session);
- the host gains data of record (learner work that cannot be rebuilt), which reopens the backup posture and Defender for Servers;
- the auth syslog shows sustained credential attacks, which reopens Defender for Servers and the inbound policy;
- a second lab host is wanted, which reopens the single-workspace and single-Compose assumptions;
- code-server is to open inside the pane rather than from Coder's app button, which leaves it (the amendment "Coder in the site's panes"; done 2026-09-28 by the lab launcher, the note under that amendment);
- the
hostinger/hostingerprovider changes itshostinger_vpsresource incompatibly or is abandoned.
Related decisions and references¶
- ADR 0004: the labs Function App as a separate trust boundary, superseded by ADR 0019, which keeps the boundary as a contract inside one app
- ADR 0006: Entra ID for administrators only
- ADR 0015: the USD 150 ceiling
- ADR 0021 (number reused) and ADR 0025: why a self-hosted runner is rejected in a public repository
- ADR 0022: the alerting the host joins
- Target architecture §5.3, corrected alongside this record
- Labs host: the estate record for the host
- ADR 0035: the AddOn pane model, which reuses
decision 4's status proxy and the panes-only amendment for independently
built tools, and amends this record's "Compose is for Coder only" line by
adding single containers run by the
addonsrole as a second workload kind on the host (its decision 8, awaiting the owner's review) - Required inputs §4.7: the inputs this record names
- Epics: #656 (the hybrid lab host), #657 (Landing Zone Builder), #658
(
hcw-labimage and Docker sandbox), #659 (browser labs on Coder) vps-agent/index.js,vps-agent/lib/capabilities.js,functions/src/lib/labs.js,functions/src/functions/labs-http.js,frontend/staticwebapp.config.json