HaltSQL Console: deployment
haltsql-console is the control plane for a HaltSQL deployment. It reads what
the verifiers (haltsql-server, haltsql-proxy, haltsql-mcp) already
publish (audit records, GET /v1/admin/status and /v1/admin/report,
/readyz, /metrics) and the policy files they load. It is never in the
query path: if the console is down, enforcement does not change. See
architecture.md for the design, config.md for
every configuration key and api.md for the HTTP API.
This guide covers running it locally (Docker Compose or the bare binary), on Kubernetes with the Helm chart, and on AWS, Google Cloud and Azure, both on their Kubernetes services and on their serverless container platforms.
| Path | What it is |
|---|---|
Dockerfile (target console) | The ghcr.io/haltsql/haltsql-console image: static binary on distroless/static:nonroot. |
deploy/console/ | Example config, the local Compose stack, demo-traffic.sh. |
deploy/helm/haltsql-console/ | Helm chart. Values reference in its README. |
deploy/cloud/{aws,gcp,azure}/console/ | Values overlays, IAM, ECS / Cloud Run / Container Apps definitions. |
deploy/terraform/examples/console-kubernetes/ | Terraform root module: the chart through helm_release. |
What every deployment needs
Whatever the platform, the console needs the same five things:
-
One replica and a persistent
state_dir. Cases, the activity log, signal workflow state and evidence packs are the only durable state, kept as files instate_dir(/var/lib/haltsql-consolein the image). Run exactly one replica per environment on a single-writer volume, and stop the old instance before the new one starts (KubernetesRecreate, ECSminimumHealthyPercent: 0, Cloud Run and Container AppsmaxScale: 1). Everything else (the record index, fleet snapshots, signals) is rebuilt from the verifiers at startup./readyzanswers503whilestate_diris not writable. -
An admin token on every verifier. The console polls each verifier's admin API with a bearer token. On the verifier, that token is an ordinary
server.auth.tokensentry (every token is pinned to a policy) whose name is listed inserver.admin.callers:# verifier config (haltsql-server)server:auth:tokens:- name: consoletoken_sha256: ${env:HALTSQL_CONSOLE_TOKEN_SHA256}policy: analytics-agent # any loaded policy; used only for admin callsadmin:enabled: truecallers: [token:console] # or oidc:<provider>, mtls:<identity># console configverifiers:- name: haltsqlurl: http://haltsql.haltsql.svc:8080token: ${env:HALTSQL_ADMIN_TOKEN} # the raw token, from a secretworkload: deployment/haltsql # optional: restarts from FleetThe admin endpoints are read-only. With the
haltsqlHelm chart, addserver.adminthroughextraConfigand the token throughtokens. -
The audit trail.
audit.sourcespoints at what the verifiers write: files (a shared volume),s3://bucket/prefix/orgs://bucket/prefix/. Raw SQL is only in the records if the verifiers setaudit.log_raw_sql. -
The verifiers' policies.
policies.server_config(orpolicies.files) points at the same config and policy files the verifiers load, mounted read-only, so the Playground, decision trails and exposure use exactly the verifiers' policies. -
Authentication in front. One of: OIDC (
auth.oidc), an identity-aware proxy that is the only way to reach the console (auth.proxy, for example the chart's oauth2-proxy sidecar), or automation tokens (auth.tokens). Roles come from groups (auth.roles), so the identity source must provide group membership. Never expose the console publicly; keep it on an internal load balancer or behind a private ingress.
Secrets (admin tokens, GitHub token, webhook URLs) go in ${env:NAME},
${vault:...}, ${aws-sm:...} or ${gcp-sm:...} placeholders, never in the
file. Resolved values are never logged or returned by the API.
The image
docker build --target console -t haltsql-console-local:dev . # or: make console-image
scripts/console-docker-smoke.sh # or: make console-smoke
Release images are ghcr.io/haltsql/haltsql-console:<version>, multi-arch
(linux/amd64, linux/arm64), built by the release workflow from the same
builder stage as the haltsql image, with SPDX SBOM and SLSA provenance
attestations, signed keylessly with cosign. Pin by digest in production; the
identity regexp accepts only the release workflow on a version tag, not any
workflow or branch of the repository:
cosign verify ghcr.io/haltsql/haltsql-console@sha256:<digest> \
--certificate-identity-regexp '^https://github.com/HaltSQL/HaltSQL/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/haltsql/haltsql-console@sha256:<digest> --repo HaltSQL/HaltSQL
| Property | Value |
|---|---|
| Base | gcr.io/distroless/static-debian12:nonroot (no shell, no libc, no package manager) |
| User | 65532:65532 |
| Root filesystem | Read-only (docker run --read-only, readOnlyRootFilesystem: true) |
| Entrypoint / command | haltsql-console / --config /etc/haltsql-console/console.yaml |
| Port | 8090 (HALTSQL_CONSOLE_LISTEN=0.0.0.0:8090) |
| Writable volume | /var/lib/haltsql-console (state_dir, VOLUME, owned by 65532) |
| Frontend | /usr/share/haltsql-console/web is reserved for frontend_dir |
| Health | HEALTHCHECK runs haltsql-console --healthcheck (GET /healthz on the listen port). Orchestrators should use HTTP probes: liveness /healthz, readiness /readyz. |
| Signals | SIGTERM/SIGINT stop the listener and exit 0; configuration errors exit 2. |
--listen > HALTSQL_CONSOLE_LISTEN > listen: in the config, so listen:
has no effect inside the image. Change the address with --listen or the
environment variable (--healthcheck probes that same address, using
loopback when it listens on all interfaces).
Local: Docker Compose
deploy/console/docker-compose.yaml
runs a complete stack from this checkout:
| Service | What it does |
|---|---|
postgres | PostgreSQL 17 with a small app schema (local/seed.sql). |
haltsql | haltsql-server with local/haltsql.yaml: two policies (saas-analytics enforced, support-bot in shadow mode), server.admin enabled for the console's token, a file audit sink on the shared audit volume, log_raw_sql: true. Port 127.0.0.1:18080. |
console | haltsql-console with console.local.yaml (demo tokens: demo-admin-token is an admin, demo-automation-token an auditor): reads the audit file read-only, polls the server's admin API, loads the server's config. Port 127.0.0.1:8090. |
make console-up # docker compose -f deploy/console/docker-compose.yaml up -d --build
make console-demo # deploy/console/demo-traffic.sh
curl -s -H 'authorization: Bearer demo-admin-token' localhost:8090/api/v1/me
curl -s -H 'authorization: Bearer demo-admin-token' 'localhost:8090/api/v1/overview?range=24h'
make console-down # stops the stack and deletes its volumes
demo-traffic.sh sends a mix of queries through POST /v1/verify: allowed
tenant-scoped reads, denials (PII column, DELETE, missing LIMIT, system
catalog), cross-tenant attempts (another tenant's literal, OR across
tenants, no tenant predicate) and shadow-mode would-be denials.
ROUNDS=20 produces enough cross-tenant records to trip the cross-tenant
signal; EXECUTE=1 also runs each allowed query on the stack's Postgres as a
read-only role, the way an agent uses HaltSQL (verify, then execute).
The stack is a demo: the tokens in local/haltsql.yaml and
console.local.yaml are well-known strings, and both ports are bound to
127.0.0.1. It does not use dev mode: dev mode only runs on a loopback listen
address, and inside the container the console listens on 0.0.0.0. All containers run
read-only with every capability dropped.
Local: the bare binary
opam exec -- dune build ./bin/console # or: make console
cp deploy/console/console.example.yaml console.yaml # edit verifiers, audit, auth
mkdir -p console-state
_build/default/bin/console/main.exe --config console.yaml --listen 127.0.0.1:8090 --dev admin
--dev ROLE authenticates every request as a local user with that role. It
replaces every other auth method in the config, and the console refuses to
start with it unless listen is a loopback address (see
Dev mode).
Release builds are static (HALTSQL_STATIC=1 dune build --profile release ./bin/console, on musl). For a VM, run it under systemd with the same
hardening as deploy/systemd/haltsql-server.service
and StateDirectory=haltsql-console.
Kubernetes: Helm
kubectl -n haltsql create secret generic haltsql-console-env \
--from-literal=HALTSQL_ADMIN_TOKEN="$CONSOLE_ADMIN_TOKEN"
helm upgrade --install haltsql-console deploy/helm/haltsql-console -n haltsql \
-f my-console-values.yaml
The chart (values reference) renders:
- a Deployment with one replica and the
Recreatestrategy, the same hardening as thehaltsqlchart (uid/gid 65532, read-only root filesystem, no capabilities,RuntimeDefaultseccomp, no service-link env vars, no service account token at pod level; with restarts enabled the token is projected into the console container only, never the oauth2-proxy sidecar orextraContainers), and startup, liveness and readiness probes. The chart has no dev mode: it refusesconsole.devRoleand--devinextraArgs, since dev mode needs a loopback listen address and signs every request in; - a PersistentVolumeClaim for
state_dir(ReadWriteOnce, 10 Gi;persistence.existingClaimto bring your own); - a ConfigMap with the console config (
console.config; the chart setslistenandstate_dir), or your own (console.existingConfigMap). The defaultaudit.max_recordsis 200,000: an index entry takes about 2.4 KB of memory, so keepmax_recordsx 2.4 KB under half ofresources.limits.memory(1 Gi by default) and raise both together. The console's own default of 2,000,000 records needs about 5 GB; - secrets from
existingSecret(every key becomes an environment variable for${env:...}placeholders), so tokens live in External Secrets, the Secrets Store CSI driver or Sealed Secrets, never in values; - the verifiers' config and policies mounted read-only at
/etc/haltsql/configand/etc/haltsql/policies(verifierConfig; with thehaltsqlchart these are the<release>-configand<release>-policiesConfigMaps); - a Service (ClusterIP) and an optional Ingress;
- a ServiceAccount and, with
rbac.create, a namespaced Role and RoleBinding that allow onlypatchon the listed Deployments and StatefulSets (verifier restarts); the chart then setsfleet.kubernetes.namespaceand projects the token into the console container. RBAC cannot limit a patch to therestartedAtannotation the console sets, so the Role allows any patch of those workloads (image, command, env); see Keeping it private for an admission policy that narrows it; - a NetworkPolicy: ingress only on the HTTP port from
ingressFrom(empty = every pod in the release namespace); egress only to DNS, the verifiers (pods of thehaltsqlchart on 8080 by default), TCP 443 (IdP, GitHub, Slack, PagerDuty, catalogs, S3/GCS, secret managers, the Kubernetes API) andextraRules; - an optional oauth2-proxy sidecar (
oauth2Proxy.enabled): the console then listens on127.0.0.1only, the Service points at the sidecar, and the chart setsauth.proxyto trustX-Forwarded-Email,X-Forwarded-GroupsandX-Forwarded-Preferred-Username, which oauth2-proxy sets from the session and overwrites on every request, withtrusted_proxies: [127.0.0.1/32, "::1/128"](the console refuses anauth.proxywithouttrusted_proxiesor a shared secret). Setoauth2Proxy.allowedGroups(or a provider group flag inextraArgs) so only the console's groups can sign in; the release notes warn otherwise. Anauth.proxyof your own, without the sidecar, must settrusted_proxiesorsecret_header+secret_sha256, and without the secret the chart also requires a non-emptynetworkPolicy.ingressFrom; - a PodDisruptionBudget (off by default) and a ServiceMonitor (off; the console has no Prometheus endpoint yet).
The verifiers' NetworkPolicy must admit the console, even in the same
namespace: a policy whose ingressFrom lists only the agents' namespace
drops the console's admin API calls. The shipped verifier policies
(values-gke.yaml, the EKS and
AKS networkpolicy-shared.yaml) admit pods labelled
app.kubernetes.io/name: haltsql-console in their namespace; add the same
podSelector to your own:
networkPolicy: # the haltsql chart's values
ingressFrom:
- namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: agents } }
- podSelector: { matchLabels: { app.kubernetes.io/name: haltsql-console } }
If the console runs in a different namespace, put a namespaceSelector for
it in the same entry as that podSelector, and set rbac.namespace to the
verifiers' namespace.
Plain manifests: helm template haltsql-console deploy/helm/haltsql-console -n haltsql -f my-values.yaml > console.yaml produces reviewable YAML for
GitOps tools that do not run Helm.
Terraform
deploy/terraform/examples/console-kubernetes
installs the chart with helm_release next to a haltsql release (for
example one created by the haltsql-kubernetes module), wiring the verifier
URL, the restart Role, the verifiers' ConfigMaps and the state volume. Cloud
specifics come from the overlays below through values_files:
cd deploy/terraform/examples/console-kubernetes
terraform init
terraform apply -var kube_context=my-eks \
-var 'values_files=["../../../cloud/aws/console/eks.values.yaml"]'
The admin-token Secret is referenced by name (admin_token_secret) and is
never written to Terraform state. automation_tokens are applied after
values_files, because Helm replaces lists: a values file that sets
console.config.auth.tokens would otherwise drop them silently. The cloud
overlays leave auth.tokens unset for the same reason.
AWS
EKS with the chart
deploy/cloud/aws/console/eks.values.yaml
installs the console next to the shared service from
deploy-aws.md (namespace
haltsql):
kubectl -n haltsql create secret generic haltsql-console-env \
--from-literal=HALTSQL_ADMIN_TOKEN="$CONSOLE_ADMIN_TOKEN"
kubectl -n haltsql create secret generic haltsql-console-oauth2 \
--from-literal=OAUTH2_PROXY_CLIENT_ID=... --from-literal=OAUTH2_PROXY_CLIENT_SECRET=... \
--from-literal=OAUTH2_PROXY_COOKIE_SECRET="$(openssl rand -base64 32 | tr -- '+/' '-_')"
helm upgrade --install haltsql-console deploy/helm/haltsql-console -n haltsql \
-f deploy/cloud/aws/console/eks.values.yaml
-
IRSA. The ServiceAccount
haltsql-consoleis annotated with the rolehaltsql-console, whose trust policy (irsa-trust-policy.json) admits onlysystem:serviceaccount:haltsql:haltsql-console. Its permission policy (iam-policy.json) is read-only and scoped to the audit prefix and the secrets the console itself resolves (${aws-sm:...}); list each one by name, never the wholehaltsql-console/prefix:{ "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::corp-haltsql-audit","Condition": { "StringLike": { "s3:prefix": ["haltsql/audit/", "haltsql/audit/*"] } } },{ "Action": "s3:GetObject", "Resource": "arn:aws:s3:::corp-haltsql-audit/haltsql/audit/*" },{ "Action": "kms:Decrypt", "Condition": { "StringEquals": { "kms:ViaService": "s3.eu-west-1.amazonaws.com" } } },{ "Action": "secretsmanager:GetSecretValue", "Resource": ["arn:aws:secretsmanager:eu-west-1:111122223333:secret:haltsql-console/admin-token-??????"] }No
s3:PutObjectorDeleteObject: the console never writes the audit trail. AWS credentials come from the standard chain (web identity under IRSA), as for the server (secrets.md). -
Audit source.
{ verifier: auto, s3: "s3://corp-haltsql-audit/haltsql/audit/", region: eu-west-1 }, the bucket and prefix of the verifiers' S3 Object Lock sink. -
Secrets. The admin token comes from the
haltsql-console-envSecret (synced from Secrets Manager by External Secrets Operator, for example), or directly from Secrets Manager withtoken: ${aws-sm:haltsql-console/admin-token}andconsole.config.secrets.aws.region. -
Sign-in. An internal ALB (
alb.ingress.kubernetes.io/scheme: internal, TLS 1.3 policy, ACM certificate) forwards to the oauth2-proxy sidecar, which signs users in with your IdP (Okta, Entra ID, Cognito) and passesX-Forwarded-Email/-Groupsto the console. The ALB's ownauth-type: oidcaction is not used: it forwards identity inx-amzn-oidc-*headers without a plain groups header, so the console could not map users to roles. -
State. An EBS
gp3volume (EBS CSI driver), withhelm.sh/resource-policy: keepsohelm uninstalldoes not delete it. -
Restarts.
rbac.create: truewithdeployments: [haltsql]. If the EKS API endpoint is private and not on 443 from the pod network, add its CIDR undernetworkPolicy.egress.kubernetesAPI. -
Network. The NetworkPolicy admits only the VPC CIDR (the ALB targets pod IPs) and allows egress to the verifiers, DNS and 443. Keep the console in private subnets; reach it over VPN or Direct Connect.
ECS Fargate alternative
console-task-definition.json
runs the console and an oauth2-proxy container in one Fargate task, with
state_dir on an EFS access point (POSIX user 65532, transitEncryption and
IAM authorization enabled):
- Build the config image with
Dockerfile.ecs, which bakesconsole.ecs.yamland the verifiers' config and policies into a derived image, the same pattern as the verifier on ECS (deploy-aws.md). - The console listens on
127.0.0.1:8090; only oauth2-proxy (port 4180) is registered with an internal ALB target group (health check/readyz). Secrets (HALTSQL_ADMIN_TOKEN, the oauth2-proxy client and cookie secrets) are injected by ECS from Secrets Manager; the execution role needssecretsmanager:GetSecretValueon them. The oauth2-proxy secrets live under their own name (haltsql-console-oauth2), outside thehaltsql-console/prefix, so the task role (which the console's code runs with) cannot read the cookie secret and forge oauth2-proxy sessions. console.ecs.yamlsetsauth.proxy.trusted_proxies: [127.0.0.1/32, "::1/128"]: the containers of a task share the loopback interface, and the console accepts identity headers only from that connection. oauth2-proxy admits only the console's groups (--allowed-group).- The task role gets the S3 read and Secrets Manager statements of
iam-policy.jsonpluselasticfilesystem:ClientMountandClientWriteon the access point. - Create the service with
desiredCount: 1,minimumHealthyPercent: 0andmaximumPercent: 100so ECS stops the old task before starting the new one (two tasks must never sharestate_dir). - Verifier restarts are Kubernetes-only; on ECS use
aws ecs update-service --force-new-deploymentfrom your runbooks.
Google Cloud
GKE with the chart
deploy/cloud/gcp/console/gke.values.yaml
installs the console next to the GKE shared service from
deploy-gcp.md:
kubectl apply -n haltsql -f deploy/cloud/gcp/console/iap-backendconfig.yaml
helm upgrade --install haltsql-console deploy/helm/haltsql-console -n haltsql \
-f deploy/cloud/gcp/console/gke.values.yaml
-
Workload Identity. Bind the Kubernetes ServiceAccount to a Google service account that can only read the audit prefix and the console's secrets:
gcloud iam service-accounts create haltsql-console --project "$PROJECT_ID"gcloud iam service-accounts add-iam-policy-binding \haltsql-console@$PROJECT_ID.iam.gserviceaccount.com \--role roles/iam.workloadIdentityUser \--member "serviceAccount:$PROJECT_ID.svc.id.goog[haltsql/haltsql-console]"# Read-only on the audit objects (condition: only the haltsql/ prefix).gcloud storage buckets add-iam-policy-binding "gs://$PROJECT_ID-haltsql-audit" \--member "serviceAccount:haltsql-console@$PROJECT_ID.iam.gserviceaccount.com" \--role roles/storage.objectViewer \--condition='title=haltsql-audit-prefix,expression=resource.name.startsWith("projects/_/buckets/'"$PROJECT_ID"'-haltsql-audit/objects/haltsql/")'gcloud secrets add-iam-policy-binding haltsql-console-admin-token \--member "serviceAccount:haltsql-console@$PROJECT_ID.iam.gserviceaccount.com" \--role roles/secretmanager.secretAccessorListing objects under a condition on
resource.nameneeds the bucket-levelstorage.objects.list; if your organization does not allow conditionalobjectViewer, grant it on a dedicated audit bucket instead. -
Audit source.
{ verifier: auto, gcs: "gs://PROJECT-haltsql-audit/haltsql/audit/shared/" }, the verifiers' GCS sink. -
Secrets.
token: ${gcp-sm:haltsql-console-admin-token}withsecrets.gcp.project; credentials come from the metadata server (Workload Identity). -
Sign-in with IAP. The internal Application Load Balancer (
className: gce-internal) has Identity-Aware Proxy enabled through the BackendConfig, so only principals withroles/iap.httpsResourceAccessorreach the console at all. IAP setsx-goog-authenticated-user-email(accounts.google.com:user@example.com) and a signedx-goog-iap-jwt-assertion, but no group membership, and the console derives roles from groups. The overlay therefore runs the oauth2-proxy sidecar behind IAP with the Google provider, which looks up Workspace groups through the Admin SDK (--google-group, a service account with domain-wide delegation and the read-only group scope) and passes them inX-Forwarded-Groups. If you need IAP's header alone, the console cannot map roles from it yet. -
State. A
premium-rwoPersistent Disk; schedule snapshots with aVolumeSnapshotClassor a GKE Backup plan. -
Network. The NetworkPolicy admits Google's health-check ranges and the internal ALB's proxy-only subnet; egress is DNS, the verifiers, 443 and the GKE metadata server (
169.254.169.252:988, and169.254.169.254:80on Dataplane V2), without which Workload Identity tokens, and with them the${gcp-sm:...}admin token and GCS audit reads, fail. The verifiers'values-gke.yamladmits the console's pods on the API port.
Cloud Run alternative
cloudrun-service.yaml
runs the console as a Cloud Run service with:
- one instance only:
minScale: 1andmaxScale: 1, andcpu-throttling: "false"so the background loops (audit ingestion, fleet polling, signals) keep running between requests; ingress: internaland Direct VPC egress, so it is reachable only from the VPC and reaches the verifiers over private ranges;- oauth2-proxy as the ingress container (port 4180), the console on
127.0.0.1:8090; state_diron a Filestore NFS share (gen2 execution environment). A Cloud Storage FUSE volume also mounts, but the console replaces its state files with an atomic rename, which Cloud Storage FUSE does not guarantee; use it only for disposable demos. The share must be writable by uid 65532;- the console config mounted from Secret Manager, and the verifiers' config
(the
haltsql-internal-configsecret ofservice-internal.yaml) at the verifier's own path,/etc/haltsql/config/config.yaml, with the signed bundle it loads on the same read-only Cloud Storage volume at/etc/haltsql/bundles. Without the bundle the console loads no policies and the Playground, decision trails and exposure stay empty. Granthaltsql-console@roles/storage.objectVieweron the policy bucket.
The console config (secret haltsql-console-config) points at those paths
and trusts oauth2-proxy over loopback only:
auth:
proxy:
user_header: X-Forwarded-Email
name_header: X-Forwarded-Preferred-Username
groups_header: X-Forwarded-Groups
label: oauth2-proxy
trusted_proxies: [127.0.0.1/32, "::1/128"] # required; oauth2-proxy is on loopback
roles: { admin: [haltsql-admins@acme.example], audit: [compliance@acme.example] }
policies:
server_config: /etc/haltsql/config/config.yaml
audit:
max_records: 400000 # about 2.4 KB each; fits the 2 GiB limit
Verifier restarts are not available on Cloud Run; redeploy the verifier service from your runbooks.
Azure
AKS with the chart
deploy/cloud/azure/console/aks.values.yaml
installs the console next to the AKS shared service from
deploy-azure.md:
kubectl apply -n haltsql -f deploy/cloud/azure/console/secretproviderclass.yaml
kubectl apply -n haltsql -f deploy/cloud/azure/console/audit-share.yaml
helm upgrade --install haltsql-console deploy/helm/haltsql-console -n haltsql \
-f deploy/cloud/azure/console/aks.values.yaml
-
Workload Identity. A user-assigned managed identity federated with
system:serviceaccount:haltsql:haltsql-console; the pod carriesazure.workload.identity/use: "true":az identity create -g "$RG" -n id-haltsql-consoleaz identity federated-credential create -g "$RG" --identity-name id-haltsql-console \--name haltsql-console --issuer "$AKS_OIDC_ISSUER" \--subject system:serviceaccount:haltsql:haltsql-console --audiences api://AzureADTokenExchange# Read only the console's secrets.az role assignment create --role "Key Vault Secrets User" --assignee "$CLIENT_ID" \--scope "$KV_ID/secrets/haltsql-console-admin-token" -
Secrets. Key Vault is not a console secrets provider yet. The Secrets Store CSI driver (
secretproviderclass.yaml) fetches the admin token with the workload identity and syncs it into thehaltsql-console-envSecret, which the chart exposes asHALTSQL_ADMIN_TOKENfor${env:HALTSQL_ADMIN_TOKEN}. The CSI volume is mounted (extraVolumes) because the driver syncs only for mounted pods. -
Audit source. Azure Blob is not a console audit source yet. The verifiers write one file sink per pod (
/var/log/haltsql-shared/${env:POD_NAME}.jsonl) to an Azure Files share (audit-share.yaml,ReadWriteMany, files owned by 65532), alongside the stdout or Blob sinks they already have; the console mounts the same claim read-only (auditVolume) and reads/var/log/haltsql/*.jsonl. -
Sign-in. An Application Gateway (AGIC, private frontend IP) routes to the oauth2-proxy sidecar, which signs users in with Entra ID (
provider: oidc, issuerhttps://login.microsoftonline.com/<tenant>/v2.0). Turn ongroupMembershipClaims: SecurityGroupon the app registration; group claims are object IDs, soauth.roleslists object IDs. -
State. An Azure Disk (
managed-csi-premium, or-zrsfor zone redundancy); Azure Files also works if you need to move the volume across zones. -
Network. The NetworkPolicy admits only the Application Gateway subnet; egress is DNS, the verifiers and 443 (Entra ID, Key Vault through the CSI driver, the AKS API).
Azure Container Apps alternative
containerapp.yaml
runs the console with minReplicas: 1, maxReplicas: 1 and
activeRevisionsMode: Single, internal-only ingress to an oauth2-proxy
container (Entra ID), and the console on 127.0.0.1:8090:
state_diris an Azure Files share registered as environment storage, mounted withuid=65532,gid=65532;- secrets are Container Apps secrets that reference Key Vault through the
app's managed identity (
keyVaultUrl+identity); Container Apps resolves them and the console sees plain environment variables; - the console config is mounted from a secret volume; its
auth.proxysetstrusted_proxies: [127.0.0.1/32, "::1/128"](as in the Cloud Run example above), since oauth2-proxy reaches the console over the replica's loopback; - oauth2-proxy admits only the console's Entra groups (
--allowed-groupwith the groups' object IDs); - the verifiers' file audit sinks are read from a shared Azure Files share.
Verifier restarts are not available on Container Apps; restart the verifier
revision from your runbooks (az containerapp revision restart).
Operations
Backups of state_dir
Only state_dir needs backups; everything else is rebuilt from the
verifiers. Its files are rewritten atomically, so a crash-consistent volume
snapshot is a valid backup:
| Platform | Backup |
|---|---|
| Kubernetes | VolumeSnapshot on a schedule (EBS, PD and Azure Disk CSI drivers support it), or Velero. |
| AWS ECS | AWS Backup plan on the EFS file system. |
| Cloud Run | Filestore backups. |
| Container Apps | Azure Files share snapshots or Azure Backup for file shares. |
| VM / compose | Stop the console (or snapshot the filesystem) and copy the directory. |
To restore, stop the console, restore the volume, start it again.
Evidence packs can also be downloaded (GET /api/v1/evidence/packs/{id}/download)
and archived elsewhere.
Upgrades
- Read the release notes; the console and the verifiers share a version and are released together. Upgrade the verifiers first, then the console (the console reads what the verifiers publish).
- Pin the new image digest and verify its signature (above).
helm upgrade(or a new ECS task definition, Cloud Run revision or Container Apps revision). The single replica is replaced in place (Recreate), so expect a short console outage; enforcement is unaffected. Changes toconsole.configroll the pod through thechecksum/configannotation. Changes toconsole.existingConfigMap,existingSecretorsecretEnvdo not (the chart cannot see the first two, and does not put a hash of secret values in a pod annotation): runkubectl rollout restart deployment/haltsql-consoleor use a reloader.- After the upgrade,
/readyzmust answer200and Fleet must show every verifier; the record index rebuilds from the audit sources in the background.
Roll back with helm rollback (or the previous revision). Take a snapshot of
state_dir before upgrades that the release notes say change its format.
Keeping it private
-
Expose the console only through an internal load balancer or a private ingress with authentication in front; keep
service.type: ClusterIP. -
Enable
auth.proxyonly when the proxy is the sole path to the console (the chart guarantees this for its sidecar by binding the console to loopback). -
Keep the NetworkPolicy on and narrow
allowHTTPSto known CIDRs where your IdP and APIs have stable addresses. -
Verifier admin tokens are read-only, but rotate them like any secret: add the new token to the verifiers, update the console's secret, restart the console, then remove the old token.
-
The console never writes to verifiers. Restarts go through a namespaced Role that allows only
patchon the named workloads. RBAC cannot narrow that patch to thekubectl.kubernetes.io/restartedAtannotation, so a compromised console container could change those workloads' image, command or env. To close that, admit only restart patches from the console's service account, for example with a ValidatingAdmissionPolicy (Kubernetes 1.30+):apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: { name: haltsql-console-restart-only }spec:failurePolicy: FailmatchConstraints:resourceRules:- apiGroups: [apps]apiVersions: [v1]operations: [UPDATE]resources: [deployments, statefulsets]matchConditions:- name: consoleexpression: request.userInfo.username == "system:serviceaccount:haltsql:haltsql-console"validations:- expression: >-object.spec.template.spec == oldObject.spec.template.spec &&object.spec.replicas == oldObject.spec.replicasmessage: haltsql-console may only restart workloadsBind it with a
ValidatingAdmissionPolicyBindingscoped to the verifiers' namespace.