Installing Neon Law Navigator on your own cloud

Neon Law Navigator's canonical build is just cargo build + docker build. Nothing in the workspace's default surface assumes a particular cloud account, project ID, OAuth client, or domain. To run it against production traffic you assemble three pieces — a runtime (Kubernetes, ECS, or plain Compose), a Postgres database, and a few SaaS dependencies — and wire them together through env vars documented in ../.env.example.

This page walks the end-to-end setup against GCP, because the workspace ships a working example overlay for that path. The same shape works against EKS, AKS, or self-hosted Kubernetes; see multi-cloud.md for those routes.

0. Prerequisites

1. Clone and build

git clone <your-fork-url> navigator
cd navigator
cargo build --workspace          # pulls dependencies + compiles every crate
cargo test  --workspace          # spins up testcontainers Postgres per test binary

Both commands work without any cloud account. The test suite uses testcontainers to spin up Postgres per test binary; no .env needed yet.

2. Configure your .env

Copy the template and start filling values:

cp .env.example .env

Brand bundle

The deployment operator, not a Navigator application admin, owns a brand bundle. Build the deployer's private manifest and logos into one directory:

cargo run -p cli -- ops rebrand build --file navigator.yaml --out /tmp/brand-bundle
cargo run -p cli -- ops rebrand verify --dir /tmp/brand-bundle

Branding is Neon Law by default. To ship under your own identity, set NAVIGATOR_CUSTOM_BRANDING to the bundle directory — /etc/navigator/brand for the Kubernetes mount, or any path for a non-Kubernetes install or test. When it is set the bundle must load and validate or the process fails closed; unset means the built-in Neon Law identity. The bundle contains only identity metadata and deployment static files. It never contains client documents, generated PDFs, public form blanks, archives/exports, Git LFS data, or NAVIGATOR_ASSET_BASE_URL content. For a private Kubernetes overlay, add web-brand-bundle.yaml as the patch (it sets NAVIGATOR_CUSTOM_BRANDING and mounts the bundle); replace its ConfigMap volume source with a Secret or PVC when appropriate; both web and workflow-worker mounts remain read-only.

The minimum to boot web against a real Postgres:

DATABASE_URL=postgres://user:pass@host:5432/navigator
NAVIGATOR_ENVIRONMENT=staging                    # exact; empty/unset means production
NAVIGATOR_STORAGE_BACKEND=s3                   # Garage or a conforming S3 endpoint
NAVIGATOR_STORAGE_ENDPOINT=https://s3.internal.example
NAVIGATOR_STORAGE_REGION=garage
NAVIGATOR_STORAGE_BUCKET=navigator-documents
NAVIGATOR_STORAGE_ACCESS_KEY=<secret-backed access key>
NAVIGATOR_STORAGE_SECRET_KEY=<secret-backed secret key>
NAVIGATOR_OPA_URL=http://opa:8181              # the OPA sidecar
SESSION_SECRET=<32 bytes from `openssl rand -hex 32`>
OAUTH_ISSUER_URL=https://accounts.google.com
OAUTH_CLIENT_ID=<your client id>
OAUTH_CLIENT_SECRET=<your client secret>
OAUTH_REDIRECT_URI=https://www.your-domain.example/auth/callback
RESTATE_BROKER_URL=<your Restate Cloud or in-cluster operator URL>
SENDGRID_API_KEY=<key>
SENDGRID_FROM_EMAIL=<verified non-production sender>
SENDGRID_INBOUND_SECRET=<random secret>
SENDGRID_EVENTS_SECRET=<random secret>
SENDGRID_EVENTS_PUBLIC_KEY=<SendGrid signing key>
NAVIGATOR_CREDENTIAL_ENVIRONMENT=staging
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
DOCUSIGN_ACCOUNT_ID=<demo account id>
DOCUSIGN_ACCESS_TOKEN=<demo token or configure the JWT variables>
DOCUSIGN_HMAC_KEY=<demo Connect HMAC key>
DOCUSIGN_WEBHOOK_SECRET=<random secret>

Every variable is documented and inventoried inline in .env.example. Exact NAVIGATOR_ENVIRONMENT=staging selects staging; empty or unset selects production, and every other value is rejected without trimming or case folding. Production requires hosted GCS with no emulator endpoint. Normal staging requires real non-production SendGrid and DocuSign demo credentials. Only NAVIGATOR_CI_HARNESS=1 may use fakes.

Third-party integrations: a separate vendor account per environment

Some integrations talk to an external SaaS that issues real, billable, or legally binding actions — DocuSign (e-signature) today, Xero (accounting/billing) next. For these, create two accounts with the vendor: a development/sandbox account you use locally and in CI, and a production account you use only in prod. The sandbox account keeps test data — unsigned envelopes, draft invoices — out of your real books and off real signers.

NAVIGATOR_ENVIRONMENT selects only deployment wiring; it is not a general runtime mode. Credential sources remain separate:

Both sources use the same application variable names. NAVIGATOR_CREDENTIAL_ENVIRONMENT stamps the credential set and must match the deployment selector, preventing staging and production credentials from crossing. A fake provider is available only to the explicit CI harness. See third-party-integrations.md.

3. (GCP path) Provision the cloud resources

The navigator CLI ships a one-shot, idempotent provisioner for the GCP-side infrastructure — VPC, Cloud SQL Postgres, two GCS buckets, GKE Autopilot cluster, Fleet membership, Gateway static IP.

gcloud auth application-default login
cargo run -p cli -- ops gcp setup \
  --project-id YOUR_PROJECT_ID \
  --public-base-url https://www.your-domain.example \
  --region us-west2 \
  --cluster-name navigator-prod \
  --sql-instance navigator-pg \
  --vpc-name navigator-vpc \
  --gateway-ip-name navigator-gateway-ip

Each flag has a sensible default (see cargo run -p cli -- ops gcp setup --help) and falls back to a NAVIGATOR_* env var if unset. Pass --dry-run first to print the exact REST calls / gcloud invocations the run will emit.

The subcommand prints a generated Postgres password once to stderr — paste it into your Secret Manager / Kubernetes Secret immediately; there is no recovery path.

4. Adapt the example overlay

Copy examples/deploy/k8s/gke/ to a private location (or to your own kustomize overlay branch) and substitute the placeholders documented in examples/deploy/README.md:

cp -r examples/deploy/k8s/gke /tmp/my-overlay
find /tmp/my-overlay -type f \( -name '*.yaml' -o -name '*.yml' \) -print0 \
  | xargs -0 sed -i \
      -e 's|YOUR_PROJECT_ID|acme-prod-1234|g' \
      -e 's|YOUR_PROJECT_NUMBER|987654321098|g' \
      -e 's|YOUR_OAUTH_CLIENT_ID_BROWSER|...|g' \
      -e 's|YOUR_OAUTH_CLIENT_ID_GEMINI|...|g' \
      -e 's|your-domain.example|acme.com|g'

Create the runtime Kubernetes Secret (out-of-band — kubectl create secret keeps the values out of the manifest tree):

kubectl -n navigator create secret generic navigator-web-secrets \
  --from-literal=DATABASE_URL='...' \
  --from-literal=OAUTH_CLIENT_SECRET='...' \
  --from-literal=SESSION_SECRET="$(openssl rand -hex 32)" \
  --from-literal=RESTATE_BROKER_URL='...' \
  --from-literal=RESTATE_AUTH_TOKEN='...' \
  --from-literal=SENDGRID_API_KEY='...' \
  --from-literal=SENDGRID_FROM_EMAIL='noreply@your-domain.example' \
  --from-literal=SENDGRID_INBOUND_SECRET="$(openssl rand -hex 32)" \
  --from-literal=SENDGRID_EVENTS_SECRET="$(openssl rand -hex 32)" \
  --from-literal=SENDGRID_EVENTS_PUBLIC_KEY='...' \
  --from-literal=NAVIGATOR_CREDENTIAL_ENVIRONMENT=production \
  --from-literal=DOCUSIGN_BASE_URL='https://your-account.docusign.net/restapi' \
  --from-literal=DOCUSIGN_ACCOUNT_ID='...' \
  --from-literal=DOCUSIGN_ACCESS_TOKEN='...' \
  --from-literal=DOCUSIGN_HMAC_KEY='...' \
  --from-literal=DOCUSIGN_WEBHOOK_SECRET="$(openssl rand -hex 32)"

Label the Secret so infrastructure inspection and runtime credential identity agree:

kubectl -n navigator label secret navigator-web-secrets navigator.neonlaw.org/environment=production

Apply:

kubectl apply -k /tmp/my-overlay

5. Build and push the image

The images/Containerfile.web recipe produces the multi-stage navigator-web image (build context is the repo root). Tag it for your registry and push:

TAG=$(git rev-parse --short HEAD)
docker build -f images/Containerfile.web -t my-registry/navigator-web:$TAG .
docker push my-registry/navigator-web:$TAG

Then roll your cluster onto the new tag — navigator ops ship --tag <TAG> renders the embedded manifest tree from your NAVIGATOR_* env and applies it, or set the tag via kustomize images: if you reconcile with your own controller.

6. Verify

kubectl get pods -n navigator should show navigator-web running. Hit https://www.your-domain.example/health (must return OK) and https://www.your-domain.example/ (must render the home page). The first inbound request triggers OPA, OIDC, and Restate handshakes — any missing env var crashes the pod with a structured enforce_deployment_invariants error before serving traffic, which is the loud-failure-by-design behavior.

Where things go from here