Documentation for v2.10.0. Read the latest, v2.12.0.

Operator

Deployment

Sanshain is one binary, one database and one port. This chapter is how to run it: the image, Docker and Compose for a single host, Kustomize or the Helm chart for Kubernetes, the choice between SQLite and PostgreSQL, and what an upgrade does.

What changed in 2.6.0

  • The image carries the platform CA bundle (ca-certificates), so ldaps:// against a directory with a publicly trusted certificate works without a certificate mount. Private CAs still go through EXTRA_CA_CERTS_DIR.
  • The Helm chart renders an optional backup CronJob (backup.*): a SQLite snapshot fetched from the service, or pg_dump. See Backup and restore.
  • Every release is signed with cosign and ships CycloneDX SBOMs; the weekly security run scans the released image. See Verifying a release.
  • Sanshain runs as exactly one instance: the chart and the manifests render a StatefulSet with one replica, and replicaCount and autoscaling are gone. See Scaling.

The image#

ghcr.io/paxel/sanshain-service:<tag>, built by the release workflow from the repository's Dockerfile. Release tags carry a v (v2.6.0); latest follows the newest release. The image is Alpine with sqlite-libs, tini as PID 1 and ca-certificates; the binary is /usr/local/bin/sanshain, the working directory /app, the static files /app/static. It sets one variable: DATABASE_URL=sqlite:/data/sanshain.db?mode=rwc, so the database lives under /data. It exposes port 3000 and declares a Docker HEALTHCHECK on GET /health. The Kubernetes manifests run it as UID 1000; the image itself does not switch user.

On every start the service runs its SQL migrations, creates the initial admin when the user table is empty (Getting started), claims the configured root names, and logs the instance id. A failed migration is fatal: the container exits with ERROR: Can't run SQLite migrations: … (or PostgreSQL) on stderr.

Docker and Compose#

docker run -p 3000:3000 -v sanshain-data:/data ghcr.io/paxel/sanshain-service:v2.6.0

docker-compose.release.template.yaml in the repository is the same as a Compose service: the volume on /data, init: true, restart: unless-stopped, and RUST_LOG=sanshain_service=info,tower_http=info. Replace __VERSION__ with the tag. For PostgreSQL add DATABASE_URL to its environment and drop the volume:

services:
  sanshain:
    image: ghcr.io/paxel/sanshain-service:v2.6.0
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/sanshain
    init: true
    restart: unless-stopped

Every setting is an environment variable; the complete list is Configuration.

Kubernetes with Kustomize#

deploy/kubernetes holds base manifests for kubectl apply -k:

File What it creates
configmap.yaml sanshain-config: BIND_ADDRESS, RUST_LOG, the pool sizes, cleanup interval, log buffer, INITIAL_ADMIN_USERNAME and friends, with a commented EXTRA_CA_CERTS_DIR.
secret.yaml sanshain-secrets: DATABASE_URL and INITIAL_ADMIN_PASSWORD, base64-encoded, both commented out in the template. Add the ones you set. A key left out, or set to an empty value, counts as unset: the image's SQLite default under /data, and a random initial admin password printed to the log on the first start.
pvc.yaml A 1 Gi ReadWriteOnce claim mounted at /data for SQLite.
statefulset.yaml A StatefulSet with one replica of ghcr.io/paxel/sanshain-service:v2.10.0, envFrom the ConfigMap and the Secret, UID 1000, 100m/128Mi requests and 500m/512Mi limits, liveness on /health and readiness on /ready every 10 s.
service.yaml, ingress.yaml A ClusterIP service and an Ingress with TLS; set your host and TLS secret.
backup-cronjob-sqlite.yaml, backup-cronjob-postgres.yaml Commented examples, not in kustomization.yaml. See Backup and restore.
kubectl create namespace sanshain
echo -n "postgres://user:pass@db:5432/sanshain" | base64   # into secret.yaml
kubectl apply -k deploy/kubernetes -n sanshain

The Helm chart#

deploy/helm/sanshain, chart version 2.10.0, appVersion 2.10.0. The image tag defaults to v plus the appVersion.

helm install sanshain deploy/helm/sanshain -n sanshain --create-namespace

With the default values the pod gets SQLite on the data volume and a random initial admin password in the log: the Secret renders DATABASE_URL and INITIAL_ADMIN_PASSWORD only when the value is non-empty, and the service treats an empty value as unset. To choose the password:

# my-values.yaml
secrets:
  databaseUrl: "sqlite:/data/sanshain.db?mode=rwc"
  initialAdminPassword: "…"

With an external PostgreSQL:

# my-values.yaml
persistence:
  enabled: false
secrets:
  databaseUrl: "postgres://user:password@postgres-host:5432/sanshain"
  initialAdminPassword: "…"
helm install sanshain deploy/helm/sanshain -f my-values.yaml -n sanshain

The values that matter, with their defaults:

Value Default Effect
image.repository, image.tag ghcr.io/paxel/sanshain-service, "" An empty tag means v<appVersion>.
service.type, service.port ClusterIP, 80 The service in front of container port 3000.
ingress.enabled false Renders an Ingress; ingress.hosts (default sanshain.local), ingress.tls, ingress.className and ingress.annotations shape it.
resources 100m/128Mi requests, 500m/512Mi limits The container's resources.
config.* the service defaults Rendered into the ConfigMap: rustLog (info,sanshain_service=debug), bindAddress, maxPostgresConnections, maxSqliteConnections, sqliteBusyTimeoutMs, cleanupIntervalSecs, csrfMaxAgeHours, logBufferSize, specUpdatedChannelSize, captureLogFilter, staticDir, initialAdminUsername, extraCaCertsDir.
secrets.databaseUrl "" DATABASE_URL in the chart's Secret, rendered only when non-empty. Empty keeps the image's sqlite:/data/sanshain.db?mode=rwc on the data volume; set it for PostgreSQL.
config.initialAuthMode "" INITIAL_AUTH_MODE, rendered only when non-empty: the auth mode of the first start (local, ldap, …). Empty, the instance starts in Off and the admin picks the mode.
secrets.initialAdminPassword "" INITIAL_ADMIN_PASSWORD, rendered only when non-empty. Empty, the service prints a random password to the log on the first start.
persistence.enabled true Mounts a claim at persistence.mountPath (/data). persistence.createPvc (true) creates it with persistence.size (1Gi), storageClass and accessMode (ReadWriteOnce); with createPvc: false the template mounts persistence.existingClaim, which you add yourself. Required for SQLite to survive a pod restart; set enabled: false on PostgreSQL.
extraVolumes, extraVolumeMounts [] Anything else to mount, typically a CA bundle for config.extraCaCertsDir; the values file carries a commented example.
extraEnv [] Any other environment variable of the service, as a list of name/value or name/valueFrom entries appended to the container's env; see below.
podSecurityContext, securityContext fsGroup: 1000; runAsNonRoot, UID 1000 Applied to the pod and the container, and to the backup job.
backup.* enabled: false The backup CronJob: schedule ("0 2 * * *"), engine, retain (7), existingClaim, storage.size (5Gi), tokenSecret.name/key, images.curl, images.postgres. Documented in Backup and restore.

The chart's deployment uses /health for both the liveness and the readiness probe; the Kustomize deployment uses /ready for readiness. If you want traffic withheld while the database is unreachable, point the chart's readiness probe at /ready with a post-renderer or a fork of the template. tests/helm_render_test.rs renders the chart in CI, so the values above are checked against the templates on every build.

The ConfigMap template renders the config.* keys above and the Secret the two secrets.* keys. Every other variable the service reads (PUBLIC_BASE_URL, SANSHAIN_ROOT_USERS, the OTEL_*, MCP_* and WEBHOOK_* settings, CACHE_MEMORY_MB, LOG_FORMAT, LDAP_OPERATION_TIMEOUT_SECS, …) goes into extraEnv, which is appended to the container's env as given, so a valueFrom reference to your own Secret works too:

# my-values.yaml
extraEnv:
  - name: PUBLIC_BASE_URL
    value: https://sanshain.corp
  - name: SANSHAIN_ROOT_USERS
    value: root,breakglass
  - name: OTEL_EXPORTER_OTLP_HEADERS
    valueFrom:
      secretKeyRef:
        name: otel-headers
        key: headers
helm uninstall sanshain -n sanshain

SQLite or PostgreSQL#

The backend is chosen from DATABASE_URL: postgres:// or postgresql:// selects PostgreSQL, anything else (or the variable unset) is SQLite. Nothing else differs in configuration; both run the same migrations.

The admin page shows the backend and the connection URL with the password removed (Database Configuration); switching means changing the variable and restarting. There is no migration between the two engines.

Scaling#

Sanshain runs as exactly one instance, on SQLite and on PostgreSQL alike. The Helm chart and the Kustomize manifests render a StatefulSet with one replica; the chart refuses to render when replicaCount or autoscaling is set, because both were removed in 2.10.0.

The reason is in the process, not the database. Each instance keeps its own in-memory caches (CACHE_MEMORY_MB), and a write clears only the caches of the instance that handled it; a second instance would answer from its own cache for up to 10 seconds. Live updates in the web UI reach only the browsers connected to the instance that took the upload, and the cleanup job runs in every instance. Running several instances needs that state shared first; until then, give the one pod more resources instead of more replicas.

A StatefulSet stops the old pod before it starts the new one, so on an upgrade two processes never hold the data volume at once. The data claim keeps its name (<release>-data, or sanshain-data with Kustomize), so an installation that ran as a Deployment keeps its data: on the upgrade to 2.10.0 the Deployment is deleted and the StatefulSet starts on the same claim, with one restart.

Health, readiness and version#

Route Answer
GET /health 200 with the text OK, without touching the database. Liveness: a database outage must not restart the pod.
GET /ready Pings the database. 200 {"status":"ready"}, or 503 {"status":"unavailable"} while it is unreachable.
GET /version {"version":"2.6.0","instance_id":"…"}; the id is INSTANCE_ID.

All three are unauthenticated. Metrics are in Observability.

Upgrades and migrations#

  1. Take a backup (Backup and restore).
  2. Read the changelog for the release; the "What changed" callout on each chapter of this track names what an operator configures.
  3. Change the image tag and roll. The StatefulSet stops the old pod first; migrations run on the first start of the new version, before the listener binds.

Migrations are forward-only: a database touched by a newer version cannot be used by an older binary. Rolling back means restoring the backup from before the upgrade. Since 2.4.0 the start also rewrites, once, every stored spec text whose line endings or BOM predate ADR-0008; like a migration it is fatal on failure (ERROR: Can't normalize stored spec text); success is recorded in a setting, a failed run is repeated on the next start. Environment changes need a restart; certificates under EXTRA_CA_CERTS_DIR are read once at startup.

Security#

Outbound TLS trust#

LDAPS connections verify the directory's certificate against the platform bundle plus, when set, every certificate under EXTRA_CA_CERTS_DIR. Mount the corporate CA (extraVolumes/extraVolumeMounts in Helm, the commented ca-certs volume in Kustomize) and point the variable at the mount path. Details and the startup failures are in Configuration.

Verifying a release#

The release workflow (.github/workflows/release.yml) produces three supply-chain artifacts for every tag:

Verify the signature (the tag or the @sha256:… digest you deploy):

cosign verify ghcr.io/paxel/sanshain-service:v2.6.0 \
  --certificate-identity-regexp '^https://github.com/paxel/sanshain-service/.github/workflows/release.yml@refs/tags/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Verify the SBOM attestation the same way; the attested SBOM is the predicate of the printed in-toto statement, so this also extracts it without the GitHub release:

cosign verify-attestation --type cyclonedx ghcr.io/paxel/sanshain-service:v2.6.0 \
  --certificate-identity-regexp '^https://github.com/paxel/sanshain-service/.github/workflows/release.yml@refs/tags/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  | jq -r .payload | base64 -d | jq .predicate > sanshain-service.cdx.json

A signature whose certificate names any other identity or issuer fails, so an admission policy can require exactly this one. Only a release built from a pushed tag verifies with the regexp above: a run started by hand through workflow_dispatch is signed with @refs/heads/<branch> instead.

The weekly security workflow (.github/workflows/security.yml, Mondays 05:17 UTC) scans the latest released image with grype. It fails only on high or critical findings that have a fix, and attaches two reports as workflow artifacts: the fixable findings and the full list.

Checked against v2.6.1.