This is how we set up Neon Law Foundation
Everything above is the recipe. This is the log of us following it for the Foundation's own deployment, neon-law-prod
in the neon-law-prod project, serving www.neonlaw.com. It is written down because the first install into a cold
cluster went differently from the happy path, and the difference is worth knowing before you hit it.
The Foundation's deployment is the third row in our matrix and the one that holds real pro bono matters, so it went last
— after the same release had proven itself on neon-law-stg and neon-law-prod. navigator ops gcp setup had already
built the project's half of the world: the neon-law-prod GKE Autopilot cluster, the navigator-secrets KMS keyring,
five storage buckets, and the reserved global address neon-law-prod-gateway-ip.
Then the first ops ship failed, and kept failing. Three things were in the way, in the order we hit them.
The object list was a superset. ops secrets apply --deployment <row> --deployments-dir . --dry-run failed closed
naming nine DocuSign objects. The Foundation's deployment executes no documents: it supplies no DOCUSIGN_BASE_URL,
declines the integration, and runs StubSignatureProvider. But the shared SecretProviderClass referenced all nine
anyway, plus the three engineering-webhook objects scoped to the automation home that this project must never hold. A
CSI mount fails the whole volume on one object it cannot read, so the only way past was a placeholder credential — and a
placeholder boots the real provider, because DocuSignSignatureProvider::from_env returns Some for any non-empty
value. A green deploy that fails on its first signature request, on the deployment holding real matters.
That was a genuine defect rather than a configuration mistake, and the fix was to render the object list per deployment
so the class references exactly what the deployment writes. It is described in
docs/deployment-secrets.md. If your own deployment declines an integration the manifest
names, this is the machinery that lets it.
Secret Manager was empty. With the object list correct, one command filled it:
navigator ops secrets apply --deployment <row> --deployments-dir .
Twenty-five objects written, twelve reported as skipped with their reasons. Read that skipped line — it is also the list of what this deployment's mount will not ask for.
A cold cluster has no namespace, and no Secret to preflight against. This is the one to plan around. ops ship
confirms the deployment's Secret satisfies the running binary's boot invariants before it reconciles anything, which
is the right order for every ship after the first — an unsatisfied requirement aborts a ship that has touched nothing.
On a first ship it is a standoff: the namespace is created by the apply in step 5, and the Secret is projected by the
CSI driver only while a pod mounts the volume, so neither exists when step 4 goes looking. The failure reads:
Error: kubectl get secret neon-law-prod-web-secrets failed:
Error from server (NotFound): namespaces "neon-law-prod" not found
We broke the standoff by hand: created the namespace, then seeded a plain neon-law-prod-web-secrets Secret from the
Secret Manager objects ops secrets apply had just written, so the preflight had something real to read. The projected
Secret takes the same name, so once the pods are up and the driver owns it, the seeded one is retired exactly as
"Retire the plain Secret" describes.
Do not read that paragraph as a runbook. Doing it by hand is off the invariant this whole workshop rests on — the
navigator CLI orchestrates every machine-bound flow, and reaching around it with ad hoc commands is precisely what
leaves the next operator without a path. The bootstrap belongs in the CLI, and the shape it should take is a preflight
that falls back to the key set the rendered SecretProviderClass will project when no live Secret exists yet: those
objects are already proven to resolve to an ENABLED version before the reconcile, so it checks the same property from
the authoritative source instead of from a cluster that has nothing in it. Until that lands, a first install into a cold
cluster needs a human, and you should expect it.
A cold Autopilot cluster has no nodes, and that stops the reconcile. With the Secret readable, ops ship rendered
and diffed, then failed on an admission webhook:
Error from server (InternalError): failed calling webhook
"validate.rules.gmp-operator.gke-gmp-system.monitoring.googleapis.com":
no endpoints available for service "gmp-operator"
The chain is circular and worth recognising on sight. Autopilot provisions nodes for workloads; a cluster nobody has
shipped to has none. With no nodes, the managed Prometheus operator cannot schedule — ours had been Pending for 44
hours — so its admission webhook has no endpoints, and the Rules object in the exports overlay cannot be validated.
The ship is blocked by the absence of the very workload it is trying to create.
ops observability is what breaks it, and this is exactly why the first-install order puts that command before ops ship rather than after. It applies the collector Deployment first — a workload, which makes Autopilot provision a node,
which lets the operator schedule — and only then does it wait for the webhook and apply the monitoring manifests. Run it
and watch a node appear:
navigator ops observability --deployment <row> --deployments-dir .
Skipping it, as we did, turns a documented ordering into forty minutes of diagnosis. Run the order as written.
Then the ship works. Every manifest applied, navigator-web and workflows-service both rolled out, and the five
trigger CronJobs pinned to the tag. ops ship still exited non-zero, at the last step: it re-registers the worker
with Restate and refuses to call a ship complete when that fails, because a stale handler list means webhook submissions
fail silently later. That is the check behaving correctly — RESTATE_ADMIN_URL and RESTATE_ADMIN_TOKEN live in the
deployment's encrypted tree for the operator, and CI supplies them from secrets.
The DNS cutover, and the gap it costs. The Ingress carries the annotation
kubernetes.io/ingress.global-static-ip-name: neon-law-prod-gateway-ip, so it claims the address reserved during
provisioning — wait for it to appear before touching DNS, because until GKE finishes the load balancer there is nothing
behind that IP:
NAVIGATOR_GATEWAY_IP=$(gcloud compute addresses describe neon-law-prod-gateway-ip \
--global --format='value(address)')
navigator ops dns setup --domain neonlaw.com --gateway-ip "$NAVIGATOR_GATEWAY_IP" --dry-run
Two calls: www patched off the old address, workflows created. The command never deletes. Then the wait described in
Pointing a hostname at a deployment begins, and it is a real
outage — the hostname serves neither the old site nor the new one while Google validates. Ours ran about twelve minutes
for www. FAILED_NOT_VISIBLE on the way through is normal: it records validation attempts made while DNS still
pointed elsewhere, and clears itself.
Retire the old certificate after the cutover has settled, not during it. www.neonlaw.com was previously served by
a Certificate Manager certificate authorized through an _acme-challenge.www CNAME. That record is inert once the
hostname points at GKE — managed certificates validate through the load balancer, not the ACME DNS challenge — but it is
also what keeps the old certificate renewable, and the old certificate is what makes a DNS rollback instant. Retire the
certificate, its authorization, and the record together, once you have decided not to roll back.
The lesson we would give another deployer is the one the release order already encodes: the deployment that carries real client matters goes last, and it goes last precisely because the first install is where you learn what the recipe assumes. Ours assumed a cluster that had already been shipped to once — and every gap above is one the two earlier deployments could never have found, because by the time they shipped, someone had already shipped to them.