Skip to content

Deployment (container)

TinyGuard ships as a single container image: the provider and the admin console are one binary, served from the same origin. This page covers running it directly; see Kubernetes for the Helm chart.

registry.tinyfactory.ai/tinyblox/guard:<tag>
  • Base: distroless, non-root (uid:gid 65532:65532), no shell.
  • Ports: 8080 HTTP, 8443 HTTPS, 9090 metrics.
  • Entrypoint: idp-server, which defaults to the serve subcommand.
  • Tags: sha-<commit> on every build, the released version, and latest on the release line. Deploy by digest (@sha256:…) — a tag can move, a digest cannot.

The image is private; authenticate before pulling:

Terminal window
docker login registry.tinyfactory.ai

Nothing starts without its inputs, so prepare them first:

  1. Generate the secrets file: the encryption key. Keep a copy.
  2. Write the configuration, or set the settings as GUARD_<SECTION>_<KEY> environment variables. config check validates it without starting anything.
  3. Choose the first administrator: an address and a password, or let the first start generate one.
  4. Run it.

Kubernetes follows the same sequence with Secrets instead of files; see Kubernetes.

The secrets file holds the deployment’s encryption key and any value the configuration refers to by a $SECRETS.<name> marker. Generate one once, with nothing but the image (--user lets the container write into your directory):

Terminal window
IMAGE=registry.tinyfactory.ai/tinyblox/guard:<tag>
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" -w /work \
"$IMAGE" generate-secrets --output secrets.toml

Keep it out of version control and back it up: the encryption key in this file is what makes stored client secrets, provider credentials, and tenant values readable. Losing it loses them.

Any configuration string can pull a value from the file instead of holding it inline:

[storage]
password = "$SECRETS.pg_password"

For Vault, AWS/GCP/Azure secret managers, mounted files, and rotating the encryption key, see Secret sources and Envelope key rotation.

Every key is documented in the configuration reference. A minimal production configuration:

[server]
public_url = "idp.example.com"
listen_scheme = "http"
listen_host = "0.0.0.0"
http_port = 8080
console_static_dir = "/app/console"
[server.metrics]
enabled = true
listen_host = "0.0.0.0"
port = 9090
[storage]
mode = "embedded"
data_directory = "/app/data"

Validate a configuration before you deploy it. config check loads it exactly as serve would, environment included, runs the same checks, touches no network, and exits non-zero with the reason. Give it the environment you will run with:

Terminal window
docker run --rm \
-e GUARD_SERVER_PUBLIC_URL=id.example.com -e GUARD_STORAGE_MODE=postgres \
-v /etc/tinyguard/config.toml:/app/config/config.toml:ro \
-v /etc/tinyguard/secrets.toml:/app/secrets/secrets.toml:ro \
registry.tinyfactory.ai/tinyblox/guard:latest \
config check --config /app/config/config.toml --secrets /app/secrets/secrets.toml

Add --print to see the effective configuration (secrets redacted), and an unknown GUARD_* variable is an error — GUARD_SERVER_HTTP_PROT is reported with the name you meant. validate-config checks the file alone and ignores the environment. Every name is in the environment reference.

generate-config writes a commented template of every section when you would rather start from the full surface.

Set listen_scheme = "http" and terminate TLS at your proxy or ingress — the provider then issues https://<public_url> URLs while serving plain HTTP inside the network. Tell it which proxies to trust so client addresses and forwarded headers are read from the right hop:

[server]
proxy_enabled = true
trusted_proxies = ["10.0.0.0/8"]

List the addresses your proxy connects from (a single address or a network). The client address is then the last address in X-Forwarded-For that is not one of your proxies; entries a client adds itself are ignored. Sign-in, the admin console and the API all use this one address, and a session only works from the address it signed in from. Requests from any other peer keep their own address.

To serve TLS from the container instead, set listen_scheme = "https" and mount a certificate:

[server.tls]
cert_path = "/app/tls/tls.crt"
key_path = "/app/tls/tls.key"

Self-signed material (self_signed = true) is for development only.

On the first start against an empty store, the provider seeds one administrator and grants it the tenant and platform administration roles:

  • GUARD_BOOTSTRAP_ADMIN_EMAIL — the address (default admin@idp.local).
  • GUARD_BOOTSTRAP_ADMIN_PASSWORD_PLAIN — a password, hashed at boot; or GUARD_BOOTSTRAP_ADMIN_PASSWORD_ARGON2ID — a pre-computed Argon2id hash.

To make a hash with nothing but the image (it reads the password from stdin, so it stays out of your shell history):

Terminal window
printf '%s' 'a-long-passphrase' | docker run --rm -i "$IMAGE" hash-password

Without either variable, a random password is generated and printed once to the server log. Sign in at https://<public_url>/auth/v1/, change the password, then remove the variables.

To read back a generated one-time value later:

Terminal window
idp-server bootstrap get --config … --secrets … --format json

Do the three steps above first: you need secrets.toml, a config.toml (or the settings as GUARD_* variables), and the first administrator’s details. The image’s default command is a demo configuration (loopback-oriented, self-signed TLS, embedded storage). Real deployments mount their own configuration and secrets and name them explicitly:

Terminal window
docker run -d --name tinyguard \
-p 8080:8080 \
--read-only --tmpfs /tmp \
-v "$PWD/config.toml:/app/config/config.toml:ro" \
-v "$PWD/secrets.toml:/app/secrets/secrets.toml:ro" \
-v tinyguard-data:/app/data \
-e GUARD_BOOTSTRAP_ADMIN_EMAIL=admin@example.com \
-e GUARD_BOOTSTRAP_ADMIN_PASSWORD_PLAIN='<a strong password>' \
registry.tinyfactory.ai/tinyblox/guard:latest \
serve --config /app/config/config.toml --secrets /app/secrets/secrets.toml

Three things to know about the filesystem:

  • The root filesystem can be read-only. The process writes only under the data directory and /tmp; mount /tmp as a tmpfs (--tmpfs /tmp).
  • console_static_dir must point at the bundle baked into the image (/app/console). Do not mount over it.
  • Never bake secrets into an image. They are files you mount, or values a secret manager resolves at boot.
Mode Use Notes
embedded Single node, or a first deployment The database lives in data_directory; give it a volume. One instance only.
postgres Multiple replicas, managed backups, a shared database server Any PostgreSQL 14 or newer. See below.
[storage]
mode = "postgres"
host = "db.internal"
user = "tinyguard"
password = "$SECRETS.pg_password"
db_name = "tinyguard"
schema = "tinyguard"
tls_mode = "verify-full"
ca_file = "/etc/ssl/pg-ca.pem" # omit to use the system trust store
pool_size = 10

TinyGuard works with any PostgreSQL 14 or newer, however it is run. It asks for four things and does a few more on its own account.

A database and a schema. It keeps everything in one named schema (tinyguard unless you set schema) and never uses public or an unqualified table name, so it can share a database with other components without colliding with them. The login needs USAGE and CREATE on that schema. If the schema does not exist and the login may not create it, the error says what to run: CREATE SCHEMA tinyguard AUTHORIZATION <login>.

TLS that means what it says.

tls_mode Encrypted Server’s certificate checked Host name checked
disable no – –
prefer if offered no no
require yes no no
verify-ca yes yes no
verify-full yes yes yes

Only verify-full tells you that you reached the server you meant to. prefer and require stop a passive observer but not someone in the path, so use verify-full anywhere but a development machine. It is the default, so a database without TLS has to be named explicitly with tls_mode = "disable". Point ca_file at the authority that signed the server’s certificate, or leave it out to use the system trust store. An unrecognised mode is a configuration error: earlier builds read anything they did not recognise as prefer, and connected without TLS at all.

A connection budget. Each replica holds up to pool_size connections (default 10), opened as needed. The server must allow the sum:

max_connections >= replicas x pool_size + a few for migrations and tools

Size pool_size from your concurrency, not your replica count: a pool of 4 carried 400 simultaneous operations in testing. Give the login a CONNECTION LIMIT to match, so one component cannot use up the server.

Migrations that you control. By default every replica applies pending schema steps at boot, taking turns on a database lock so concurrent starts do not race. To keep the runtime login to data access only, run the steps yourself with a login that may change the schema:

Terminal window
GUARD_STORAGE_USER=tinyguard_owner GUARD_STORAGE_PASSWORD=… \
idp-server migrate --config config.toml --secrets secrets.toml

and set auto_migrate = false. Boot then only checks that the schema is current and refuses to start, naming migrate, if it is not. The Helm chart can run this as a hook Job before each install and upgrade; see Kubernetes.

One leader. Some duties must run once, not once per replica: signing-key rotation, session and event cleanup, the email and event pipelines. Replicas agree on which of them does it through a lease in the database, renewed every few seconds and expiring after 30 seconds without renewal, so a crashed leader is replaced within that time. A replica that cannot reach the database assumes it is not the leader: a duty skipped for a moment is harmless, a duty run twice is not.

What it does not need. No extension, no superuser, no replication setup. Tenants are a column in the data (derived from the key, indexed), so a tenant’s size is a query and its data is a range rather than a scan.

TinyGuard uses prepared statements and sends statement_timeout as a startup parameter, so a pooler in transaction mode needs two things: PgBouncer 1.21 or newer with max_prepared_statements set, and either ignore_startup_parameters = options or statement_timeout_seconds = 0. It uses no session-level locks and no LISTEN/NOTIFY. A pooler is optional: the pool is already bounded.

Give each component its own database or its own schema, its own login, and a CONNECTION LIMIT; set a statement_timeout for the login; and count every component’s connection budget against max_connections. A starting point for the runtime login, to adjust to your workload:

ALTER ROLE tinyguard CONNECTION LIMIT 24; -- replicas x pool_size, plus headroom
ALTER ROLE tinyguard SET statement_timeout = '30s'; -- the service also sends its own (storage.statement_timeout_seconds)
ALTER ROLE tinyguard SET idle_in_transaction_session_timeout = '60s';

The service sets statement_timeout on its own sessions, so the role setting is a backstop for anything else that connects as that login. It never holds a transaction open across a request, so a short idle_in_transaction_session_timeout costs nothing and frees a leaked session. In production, keep the identity provider’s database out of the failure domain of the services that depend on signing in to it — an outage should not take down both.

Endpoint Purpose
/livez Liveness — the process is up. Never touches storage.
/readyz Readiness — 200 while storage and the caches answer, 503 when either does not, so an instance without a database stops receiving traffic.
/auth/v1/health Detailed subsystem health (which of storage and the caches is failing).
/auth/v1/version Build version and whether an update is available.
:9090/metrics Metrics ([server.metrics]). Keep it in-cluster.

/livez and /readyz are unversioned and unauthenticated, on the main port, and answer directly (no redirect) with an empty body: the status is the answer. They are the platform’s standard probe paths, so one probe definition fits every component. The versioned spellings /auth/v1/ping and /auth/v1/ready answer identically and remain.

Point liveness at /livez and readiness at /readyz, and give the first start a startup probe on /readyz: a restarting pod should never be sent traffic before /readyz answers, and liveness must not depend on the database — restarting a healthy process cannot repair a database that is down.

On SIGTERM the server stops accepting connections, lets in-flight requests finish for up to server.graceful_shutdown_seconds (default 10), and then hands leadership of the singleton duties to another replica so a rolling deploy does not leave them unowned. Give the container a terminationGracePeriodSeconds comfortably above the drain bound (the chart sets 30).

  1. Read the CHANGELOG for the version you are moving to.
  2. Deploy the new digest.
  3. The server applies its own storage migrations on boot; a failed migration refuses to start rather than serving half-migrated data.

Embedded storage pins you to one pod, so an upgrade is a restart with a brief outage; on Postgres, roll the deployment normally.