Deploying the runtime
In the remote topology , the runtime is a standalone control plane: it owns the data store and coordinates placement, state, and alarms for a fleet of stateless worker hosts. This page covers obtaining, configuring, and running the runtime.
If you’re using the local topology , you don’t need the runtime — skip this page.
Getting the runtime#
The recommended way to run the runtime is the published container image. Pre-compiled binaries are also available.
Use UDP for port forwarding
The runtime’s WebTransport server runs over HTTP/3 (QUIC), which is based on UDP. Whenever you publish the runtime’s port (indocker run, Compose, a firewall rule, or a load balancer) make sure it’s the UDP port, not TCP.
Container image (Docker or Podman)#
The runtime is published to the GitHub Container Registry as ghcr.io/italypaleale/francis. Images are multi-arch (linux/amd64, linux/arm64, and linux/arm/v7). The available tags are:
- A full version, e.g.
1.2.3. - Floating
1.2and1tags that track the latest patch/minor.
Mount your configuration file into the container at a well-known path
so the runtime discovers it automatically. The example below mounts the config at /etc/francis/config.yaml, mounts a named volume for the SQLite data store, and publishes the UDP port:
docker run \
--name francis-runtime \
-p 7400:7400/udp \
-v "$(pwd)/config.yaml:/etc/francis/config.yaml:ro" \
-v francis-data:/data \
ghcr.io/italypaleale/francis:1Or, with Podman :
podman run \
--name francis-runtime \
-p 7400:7400/udp \
-v "$(pwd)/config.yaml:/etc/francis/config.yaml:ro" \
-v francis-data:/data \
ghcr.io/italypaleale/francis:1The image is built on a distroless base and runs as a non-root user (UID 65532). When using SQLite, persist the SQLite store on a volume, then point
provider.connectionStringat the mounted volume (for example/data/data.db). The data directory must be writable by that user.
The image ships with a HEALTHCHECK that probes the locally-running runtime, so docker ps and orchestrators report container health automatically.
Docker Compose#
To run the runtime under Docker Compose, drop this into a docker-compose.yaml next to your config.yaml:
services:
runtime:
image: ghcr.io/italypaleale/francis:1
ports:
- "7400:7400/udp"
volumes:
- ./config.yaml:/etc/francis/config.yaml:ro
- francis-data:/data
restart: unless-stopped
volumes:
francis-data:Then start it in the background:
docker compose up -dKubernetes (Helm)#
A Helm chart that runs the runtime as a StatefulSet is published to the GitHub Container Registry as an OCI artifact, alongside the container image. Its version always matches the runtime version it deploys.
JWT bootstrap is enabled by default, so find your Kubernetes cluster’s issuer and JWKS endpoint with kubectl get --raw /.well-known/openid-configuration | jq '{issuer, jwks_uri}' and provide them when you install:
helm install francis oci://ghcr.io/italypaleale/charts/francis \
--version 1.2.3 \
--namespace francis --create-namespace \
--set-string 'runtimePSKs[0]=change-me-runtime-psk' \
--set-string 'bootstrap.jwt.issuer=<your Kubernetes OIDC issuer>' \
--set-string 'bootstrap.jwt.audience=francis-runtime' \
--set-string 'bootstrap.jwt.jwksURL=<your Kubernetes OIDC JWKS URL>'That deploys one replica backed by SQLite on a persistent volume. For a highly-available control plane, point the chart at PostgreSQL and scale up (up to four replicas):
helm install francis oci://ghcr.io/italypaleale/charts/francis \
--version 1.2.3 \
--namespace francis --create-namespace \
--set replicaCount=3 \
--set database.type=postgres \
--set-string 'database.postgres.connectionString=postgres://user:password@postgres:5432/francis' \
--set-string 'runtimePSKs[0]=change-me-runtime-psk' \
--set-string 'bootstrap.jwt.issuer=<your Kubernetes OIDC issuer>' \
--set-string 'bootstrap.jwt.audience=francis-runtime' \
--set-string 'bootstrap.jwt.jwksURL=<your Kubernetes OIDC JWKS URL>'SQLite is limited to a single replica, since one runtime owns the database file.
The chart creates a headless Service, so every replica gets a stable DNS name such as francis-0.francis-headless.francis.svc.cluster.local:7400. Pass all of them to remote.WithRuntimeAddresses(…) on your workers, and remember that the port is UDP.
The chart’s source lives in charts/francis
, and its README
documents the full list of options, including JWT bootstrap with projected service account tokens, OpenTelemetry export, and supplying the configuration from a Secret you manage yourself.
Pre-compiled binaries#
Pre-compiled binaries are attached to every release on the releases page
. Builds are published for Linux (amd64, arm64, armv7), macOS (arm64), and FreeBSD (amd64, arm64).
Download the archive for your platform, extract it, and run the francis binary inside:
# Replace VERSION and the platform suffix to match the release you want
VERSION=1.2.3
curl -LO https://github.com/ItalyPaleAle/francis/releases/download/v${VERSION}/francis-${VERSION}-linux-amd64.tar.gz
tar -xzf francis-${VERSION}-linux-amd64.tar.gz
# Loads a config.yaml in the current directory
./francis-${VERSION}-linux-amd64/francisConfiguration#
The runtime is configured with a YAML file.
A minimal configuration:
# Address and port the runtime's WebTransport server listens on (using UDP)
bind: "0.0.0.0:7400"
# The runtime PSKs derive the cluster CA
# Every runtime sharing these keys is the same certificate issuer
# Keep them secret
runtimePSKs:
- "change-me-runtime-psk"
# How joining hosts authenticate themselves to the runtime
bootstrap:
method: psk
hostPSK: "change-me-host-bootstrap-psk"
# Where state and alarms are stored
# The backend is inferred from the connection string
provider:
connectionString: "data.db"
# Optional: warn about slow SQL statements
# queryLog:
# slowThreshold: "250ms" # Warn-log queries slower than this
# includeParameters: false # Include parameter values in traces and SQL text logs
# Optional: warn about slow provider operations on every backend (including memory)
# operationLog:
# slowThreshold: "500ms"
log:
level: infoConfig file location#
The runtime looks for its configuration in this order:
- The path in the
FRANCIS_CONFIGenvironment variable, if set. - Otherwise, the first
config.yaml,config.yml, orconfig.jsonfound in one of these directories:- The current directory (
.) ~/.francis/etc/francis
- The current directory (
Subcommands (including print-ca, healthcheck, backup, and restore) resolve the config the same way.
Configuration reference#
| Key | Description |
|---|---|
bind | Address and port the runtime listens on. Default :8443. |
runtimeId | Optional identifier for this runtime, used in its server certificate, logs, and traces. The FRANCIS_RUNTIME_ID environment variable overrides it, so replicas sharing one config file can each get a distinct ID. When neither is set, the runtime picks a random ID on every start. |
runtimePSKs | List of runtime pre-shared keys from which the cluster CA is derived. Required. |
bootstrap.method | How hosts authenticate when joining: psk or jwt. Required. |
bootstrap.hostPSK | The shared host bootstrap secret, for method: psk. |
bootstrap.jwt.issuer / audience / jwksURL / staticJWKS | JWT validation settings, for method: jwt. |
provider.connectionString | Connection string for the data store; the backend is inferred from its scheme. postgres://… or postgresql://… selects the standard PostgreSQL provider, memory selects the non-durable in-memory provider, and anything else selects the standard SQLite provider. Prefix the same values with standalone: to use the single-runtime provider with in-memory reads and optional PostgreSQL or SQLite persistence. Required. |
provider.queryLog.enabled | Log every SQL statement at Debug level with its duration when the provider opens the connection. Default false. |
provider.queryLog.includeParameters | Include parameter values as db.query.parameter.<name-or-position> attributes in traces and in SQL logs that include query text. This may expose sensitive data. Default false. |
provider.queryLog.slowThreshold | Log a Warn record for every SQL statement that reaches this duration (e.g. "250ms"). At Info level the warning omits SQL text and parameters. Warnings are disabled by default or when the value is zero. |
provider.operationLog.enabled | Log every provider operation at Debug level with its duration (for every backend, including memory). Default false. |
provider.operationLog.slowThreshold | Log successful slow operations at Warn and domain warnings or failures at their policy level when operation logging is otherwise disabled. Warnings are disabled by default or when the value is zero. |
workloadCertTTL | Lifetime of the workload certificates issued to hosts. Default 1h. |
healthCheckDeadline | Maximum interval between host health pings. A host that has not checked in within the deadline is considered gone and its actors are placed elsewhere. Must be greater than 1s, recommended not less than 20s. Default 20s. |
alarmsPollInterval | How often the runtime polls for due alarms. Default 1500ms. |
alarmsLeaseDuration | How long an alarm lease is held while executing. Default 20s. |
shutdownGracePeriod | Grace period for a clean shutdown. Default 30s. |
log.level | debug, info, warn, or error. |
log.json | Log in structured JSON instead of text. Default false. |
Durations accept Go duration strings (e.g. "1h", "1500ms").
For exporting traces, metrics, and logs via OpenTelemetry, see Observability .
Host bootstrap#
When a worker first connects, it must prove it’s allowed to join. The runtime supports two bootstrap methods:
Pre-shared key (PSK)#
The simplest method: the worker proves knowledge of a shared secret via a channel-bound challenge-response.
bootstrap:
method: psk
hostPSK: "change-me-host-bootstrap-psk"The worker must be configured with the matching key using remote.WithHostBootstrapPSK([]byte(...)).
JWT#
The worker presents a JWT that the runtime validates against a JWKS. This suits environments that already issue identity tokens (for example, Kubernetes projected service-account tokens).
bootstrap:
method: jwt
jwt:
issuer: "https://issuer.example.com"
audience: "francis-runtime"
jwksURL: "https://issuer.example.com/.well-known/jwks.json"The worker provides the token with remote.WithHostBootstrapJWTFile("/path/to/token") (re-read on each bootstrap, so rotated tokens are picked up) or remote.WithHostBootstrapJWT(token) for a static token.
After a successful bootstrap, the runtime issues the host a short-lived workload certificate, and all later connections — to the runtime and between peer hosts — use mTLS. See Security for the full model.
Pinning the cluster CA#
To close the trust gap on a host’s very first connection, print the cluster CA and pin it on your workers:
francis print-caWhen running from a container, invoke the same subcommand inside it — for example with the config mounted at the well-known path as above:
docker run --rm \
-v "$(pwd)/config.yaml:/etc/francis/config.yaml:ro" \
ghcr.io/italypaleale/francis:1 \
print-caPass the PEM output to the worker via remote.WithPinnedCA(caPEM). Pinning is strongly recommended — especially with JWT bootstrap, where a bearer token would otherwise be exposed to a meddler-in-the-middle on the first connection. Only use remote.WithUnsafeNoPinnedCA() for local testing.
Connecting workers#
A worker is an ordinary host/remote host. The key options are the runtime address(es), the bootstrap credential, and the pinned CA:
h, err := remote.NewHost(
remote.WithAddress("10.0.0.5:7571"), // peer address other hosts reach this one at
remote.WithRuntimeAddresses("10.0.0.1:7400", "10.0.0.2:7400"), // runtime replicas
remote.WithHostBootstrapPSK([]byte(os.Getenv("FRANCIS_HOST_BOOTSTRAP_PSK"))),
remote.WithPinnedCA(caPEM),
)WithRuntimeAddresses accepts multiple runtime replicas; the host connects to one at a time and rolls over to another on failure.
Running multiple runtime replicas#
For availability, you can run multiple runtime replicas that share the same runtimePSKs (so they form one certificate issuer) and the same database. Workers list all of them in WithRuntimeAddresses and fail over automatically.
Database#
The runtime stores all state and alarms in its configured provider, selected by the provider.connectionString scheme:
- PostgreSQL is recommended for production. Use a standard connection string, e.g.
connectionString: postgres://user:pass@host:5432/dbname. - SQLite works well when a single runtime owns the database. Set
connectionStringto a file path, e.g.data.db.
Do not place the SQLite file on a networked filesystem like NFS/SMB. - In-memory is non-durable and intended for testing only. Set
connectionString: memory. - Standalone with persistence keeps the working set in memory while persisting every change to PostgreSQL or SQLite. Use
standalone:postgres://…,standalone:postgresql://…, or prefix a SQLite path or DSN withstandalone:. This provider supports only one runtime replica.
standalone:memory and standalone:memory:// are aliases for memory.
To export the data store to a portable file (for backups or to migrate between backends) see Backup and restore .