Skip to content

feat(deploy): add deployment routes and stop requiring a checkout to deploy - #59

Merged
semics-tech merged 5 commits into
mainfrom
deploy/routes
Jul 31, 2026
Merged

feat(deploy): add deployment routes and stop requiring a checkout to deploy#59
semics-tech merged 5 commits into
mainfrom
deploy/routes

Conversation

@semics-tech

Copy link
Copy Markdown
Owner

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.yml carried a build: 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:

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    # edit it
docker compose --profile tls up -d

cloud-init.yaml does the same on first boot, so git isn't even installed on the host. RSAGENT_VERSION is pinned rather than tracking latest.

docs/deployment.md

The missing half of the install story — the worker had three documented routes, the control plane had none.

  • Two constraints that decide every hosting choice: one process needing two ports with the hub reachable as raw TCP, and exactly one replica.
  • Three routes with real costs — VM ≈ £32/mo on Azure, ≈ £3 on Hetzner, £0 on Oracle Always Free; Container Apps ≈ £70–85.
  • The free question answered honestly: no free managed option exists, 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) — the first wall is Postgres, not Node.

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-url when 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 and crypto.subtle doesn'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:

check result
Compose resolves to published image techsemics/remote-sql-agent:0.1.1, 0 build contexts
Both containers, /health healthy, {"status":"ok",...}
Hub TLS on 8443 subject=CN=rsagent.localtest.me
tls profile + HSTS HTTP/2, max-age=31536000
RSAGENT_HTTP_BIND isolation 127.0.0.1:8080->8080/tcp
Default profile unchanged 0.0.0.0:8080->8080/tcp
cloud-init in clean ubuntu:24.04 runs to the compose step; cert SAN, 0640 key, 0600 .env
k8s manifest kubeconform strict 7/7, applied to live k3s
lint / typecheck / test:unit pass / pass / 339/339

Three bugs found by running things rather than reading them: Compose interpolates inactive profiles (so :? on RSAGENT_DOMAIN would have broken plain up -d); the password recipe came up short 2 times in 200 and could emit / into a postgres:// URL; and Kubernetes injects <SERVICE_NAME>_PORT, so rsagent-http collides with RSAGENT_HTTP_PORT and kills the pod on Expected number, received nan.

Known limits, stated plainly

  • No Bicep for Container Apps. Whether a custom domain CNAME'd to a Container App reaches a raw TCP port isn't stated consistently in Microsoft's own docs, and that's the load-bearing assumption of the route. Documented with the test to run instead.
  • The k8s manifest hasn't been run through to a Ready pod. Parked; docs/deployment.md says so too.
  • The Windows one-liner still 404s against any containerised control plane — documented with the -PackageUrl workaround, fix tracked separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AyYg2j8FVkLjiaVcj5HCkj

semics-tech and others added 5 commits July 31, 2026 07:26
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>
@semics-tech
semics-tech merged commit 4b18faa into main Jul 31, 2026
9 checks passed
@semics-tech
semics-tech deleted the deploy/routes branch July 31, 2026 06:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant