feat(deploy): add deployment routes and stop requiring a checkout to deploy - #59
Merged
Conversation
The address workers are told to dial was always derived as the public URL's host plus the port the hub bound. That holds for the Compose deployment and stops holding the moment anything maps ports or gives the hub a name of its own — a container platform publishing 8443 on a different outside port, or a load balancer separate from the HTTP ingress. Both are ordinary shapes for the deployment routes being added. It is already wrong in our own Compose file: RSAGENT_GRPC_PORT remaps the *published* port but is never passed into the container, so remapping the hub leaves the dashboard printing the old port. Documented that where the variable is set, rather than renaming it and breaking existing .env files. RSAGENT_HUB_ADVERTISED_ADDRESS overrides the derivation. It must include the port: accepting a bare host and appending grpcPort would silently reintroduce the mismatch it exists to fix, and this string is copied into worker.yaml on every SQL host in the estate. A wrong value fails quietly — the worker retries on backoff forever while the dashboard shows it as never having connected — so it is rejected at boot instead. Also stops the install one-liners assuming the worker package lives on the hub's host. Both bootstrap scripts strip the port off --control-plane and fetch from https://<that host>/downloads/, which is only right when the hub and dashboard share a host and the dashboard is on 443. Where it is not, the command now carries an explicit --package-url / -PackageUrl rather than sending an admin to a 404 partway through installing on a production SQL host. Fixed at the point the string is built rather than in the scripts, so there is one place that knows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AyYg2j8FVkLjiaVcj5HCkj
The Compose file had no way to serve the dashboard over HTTPS, and the dashboard needs it: SQL credentials are encrypted in the browser to the target worker's public key, and `crypto.subtle` does not exist in an insecure context. Over plain HTTP the credential field disables itself, so the deployment we ship as production could not actually onboard a SQL login. `docker compose --profile tls up -d` now terminates HTTPS with a certificate Caddy obtains and renews on its own. Caddy deliberately does not front the worker hub. The hub terminates its own TLS so mTLS and the workers' pinned CA keep working end to end; proxying it would break both, and per-command signatures are what defend against a compromised terminating proxy in the first place. RSAGENT_HTTP_BIND exists because enabling the profile is not enough: with 8080 still published, the same dashboard is served over plain HTTP on a second origin where credential onboarding silently does not work. Set it to 127.0.0.1 and the only way in is through Caddy. Defaults to 0.0.0.0, so the existing deployment is unchanged. RSAGENT_DOMAIN is interpolated with `:-` rather than `:?`. Compose interpolates every service whether its profile is active or not, so a required-variable marker breaks plain `docker compose up -d` for everyone not using the profile — verified, not assumed. Unset, Caddy refuses to start but blames `unrecognized global option: encode`, because an empty site address turns the block into a global options block; that is recorded next to the variable so the next person does not have to work it out from the message. Also adds `pnpm dev:cert`. `*.crt` and `*.key` are gitignored, so deploy/tls never exists in a fresh clone and `docker compose up -d` fails on the bind mount before anything runs. It writes a SAN certificate — without one every current TLS client rejects it for a reason that says nothing about the cause — and refuses to overwrite an existing pair. Verified against the published image: the tls profile brings up both containers, /health answers through Caddy over HTTP/2 with HSTS set, the hub still presents its own certificate on 8443, 8080 binds to 127.0.0.1 only, and the default profile still publishes 8080 on 0.0.0.0 as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AyYg2j8FVkLjiaVcj5HCkj
The VM route was documented as a runbook and nothing else, so standing up a control plane meant hand-installing Docker, hand-writing .env, and remembering the four settings that are only obvious once you have got them wrong. This does it on first boot, on anything that takes cloud-init — Azure, Hetzner, DigitalOcean, Oracle's always-free Ampere instances (the image is published for arm64), AWS, GCP. Deliberately does not run itself. RSAGENT_DOMAIN has to be edited first, and a machine that silently came up on the example domain would look like it had worked until a worker failed to connect to it. It generates a self-signed certificate for the hub rather than reusing Caddy's. The hub reads its PEM files once at startup and grpc-js cannot swap credentials on a bound server, so an ACME certificate would be served past expiry from about day 60 of every 90-day cycle, and that failure is total, silent and simultaneous across the estate — a month after the dashboard visibly renewed. Self-signed is a real posture here rather than a placeholder: workers pin it with --ca-cert, which is a stronger position than trusting any public CA. Three things verified in a clean ubuntu:24.04 container rather than assumed: - Compose takes the last definition of a repeated key, so appending to a copy of .env.example overrides the placeholders and leaves the example readable as documentation. - The Postgres password is filtered to alphanumerics. It is interpolated into a postgres:// URL, where a '/' or '@' out of base64 truncates the string and the failure presents as wrong credentials. The first recipe also came up short of 40 characters twice in 200 draws. - The whole script runs to the compose step: placeholder guard, clone, certificate with the right SAN, key at 0640, .env at 0600. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AyYg2j8FVkLjiaVcj5HCkj
One file for estates that already run a cluster: Deployment, both Services, Ingress, and the two Secrets. Postgres is out of scope — use a managed database or whichever operator the cluster already has. The two constraints that make this workload unusual are recorded next to the fields that enforce them rather than in prose someone will not read: replicas must stay 1 because the worker registry is an in-memory map, and the hub needs a layer-4 LoadBalancer rather than a second Ingress rule because it terminates its own TLS. enableServiceLinks: false is required, not hygiene. Kubernetes injects an environment variable per Service named <SERVICE_NAME>_PORT, so the rsagent-http Service sets RSAGENT_HTTP_PORT=tcp://10.43.x.x:80 — exactly colliding with the config variable — and the process dies at startup on "Expected number, received nan" without naming Kubernetes or the Service that caused it. Found by running this, not by reading it. readOnlyRootFilesystem with an emptyDir at /tmp is likewise measured. The server writes nothing to disk, but it runs its TypeScript through tsx, which caches transforms under /tmp; with a read-only root and no writable /tmp the container dies on ENOENT: no such file or directory, mkdir '/tmp/tsx-1000' which reads like a packaging fault. Confirmed both ways against the published image. Validated with kubeconform in strict mode against Kubernetes 1.30 — 7 of 7 resources valid — and applied to a throwaway k3s cluster, which is where the service-link collision surfaced. A full run to Ready is not yet finished and is tracked separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AyYg2j8FVkLjiaVcj5HCkj
…56) > Top of the stack: **#52 → #53 → #54 → #55 → #56 → this**. Review the newest commit only. ## Deploying no longer means cloning the repo `deploy/docker-compose.yml` carried a `build:` stanza pointing at `..`, which quietly made a git checkout a prerequisite for deploying — putting the whole source tree and a build toolchain on a production host to compile an image that is **already published**. It now pulls `techsemics/remote-sql-agent`. The documented path is three curled files on any host with Docker: ```bash BASE=https://raw.githubusercontent.com/semics-tech/remote-sql-agent/main/deploy curl -fsSLO $BASE/docker-compose.yml curl -fsSLO $BASE/Caddyfile curl -fsSL $BASE/.env.example -o .env docker compose --profile tls up -d ``` `cloud-init.yaml` does the same instead of cloning, so `git` is no longer even installed on the host. README and quick-start updated to match. `RSAGENT_VERSION` is now pinned in the example rather than tracking `latest` — a control plane that silently changes version on the next `docker compose pull` isn't what anyone wants from the component holding every job definition in the estate. ## `docs/deployment.md` The missing half of the install story. The worker had three documented routes; the control plane had none. - **The two constraints** that decide every hosting choice, with citations: one process needing two ports (hub as raw TCP), and exactly one replica. - **Three routes** with real monthly costs — VM ≈ £32 on Azure, ≈ £3 on Hetzner, £0 on Oracle Always Free; Container Apps ≈ £70–85; Kubernetes marginal. - **The free question answered honestly:** there is no free managed option, because this needs a 24/7 process holding open streams plus a database. The free answer is a free VM. - **Sizing** with a stated ceiling (~200 workers / 400 instances on 1 vCPU / 2 GB) and the levers in order — the first wall is Postgres, not Node. - **Things that will catch you** — the CORS exact-match trap, HTTPS being required for credential onboarding, proxy hops, the advertised hub address, and the Windows `-PackageUrl` workaround. ## Route B ships as documentation, not a template No Bicep. Whether a custom domain CNAME'd to a Container App reaches a raw TCP port is not something Microsoft's own docs state consistently — the ingress article says FQDN plus exposed port, the networking article lists only 80/443 inbound. That is the load-bearing assumption of the entire route, so it's written up **with the test to run** rather than presented as working. ## Verification Ran the standalone path from a directory containing only the three files and no checkout: | check | result | |---|---| | Compose resolves to the published image | `techsemics/remote-sql-agent:0.1.1` | | Build contexts remaining | `0` | | Both containers up | healthy | | `/health` | `{"status":"ok",...}` | | Hub TLS on 8443 | `subject=CN=rsagent.localtest.me` | | Both compose profiles | `config` valid | | cloud-init | YAML parses, embedded script passes `bash -n` | `pnpm lint`, `pnpm typecheck`, `pnpm test:unit` (339/339) pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AyYg2j8FVkLjiaVcj5HCkj Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the stack #53–#57, rebuilt as one branch off
main. The squash-merges left the stacked branches carrying both their original commits and the squashed copies, so every remaining PR conflicted. Nothing was lost — the resulting tree is byte-identical to the old stack tip.Five commits, each reviewable on its own.
Deploying no longer requires a checkout
deploy/docker-compose.ymlcarried abuild:stanza pointing at.., which quietly made a git clone a prerequisite for deploying — putting the whole source tree and a build toolchain on a production host to compile an image that is already published.It now pulls
techsemics/remote-sql-agent. The whole deployment is three files:cloud-init.yamldoes the same on first boot, sogitisn't even installed on the host.RSAGENT_VERSIONis pinned rather than trackinglatest.docs/deployment.mdThe missing half of the install story — the worker had three documented routes, the control plane had none.
Server change
RSAGENT_HUB_ADVERTISED_ADDRESS. The address workers dial was derived as the public URL's host plus the bound port, which breaks wherever a platform maps ports or the hub has its own name. It was already latently wrong in our own Compose file. Install one-liners now also carry an explicit--package-urlwhen the scripts can't derive it, instead of sending an admin to a 404 partway through installing on a production SQL host.Caddy TLS profile
docker compose --profile tls up -d. HTTPS isn't decoration: SQL credentials are encrypted in the browser andcrypto.subtledoesn't exist in an insecure context, so the deployment we shipped as production could not onboard a SQL login. Caddy deliberately does not front the hub — that would break mTLS and the workers' pinned CA.Verification
Against the published image, from a directory with no checkout:
techsemics/remote-sql-agent:0.1.1, 0 build contexts/health{"status":"ok",...}subject=CN=rsagent.localtest.metlsprofile + HSTSmax-age=31536000RSAGENT_HTTP_BINDisolation127.0.0.1:8080->8080/tcp0.0.0.0:8080->8080/tcpubuntu:24.04.envlint/typecheck/test:unitThree bugs found by running things rather than reading them: Compose interpolates inactive profiles (so
:?onRSAGENT_DOMAINwould have broken plainup -d); the password recipe came up short 2 times in 200 and could emit/into apostgres://URL; and Kubernetes injects<SERVICE_NAME>_PORT, sorsagent-httpcollides withRSAGENT_HTTP_PORTand kills the pod onExpected number, received nan.Known limits, stated plainly
docs/deployment.mdsays so too.-PackageUrlworkaround, fix tracked separately.🤖 Generated with Claude Code
https://claude.ai/code/session_01AyYg2j8FVkLjiaVcj5HCkj