Running in production
Run the standalone host the way production should: on PostgreSQL, with secrets in files, a startup mode that never migrates by surprise, behind a proxy, with probes your orchestrator can trust.
Before you start
Section titled “Before you start”- Docker with Compose v2,
curlandopenssl: Quick start and Run your own descriptor show the compose file this page builds on. - The descriptor you will serve, already working on that stack. This page uses the quick start’s default,
examples/vehicle-registry, and itsdemokey (ALVO_DEMO_KEY_SECRET).
1. Get the image
Section titled “1. Get the image”The image is published as ghcr.io/burgyn/alvo, for linux/amd64 and linux/arm64:
docker pull ghcr.io/burgyn/alvo:edgeedge is built from every push to main, and sha- with the commit’s first seven characters names one build for
good. A release tag v1.2.3 will
publish 1.2.3, 1.2 and latest; pin a version, never edge, once one exists. The compose file reads the image
from ALVO_IMAGE, so ALVO_IMAGE=ghcr.io/burgyn/alvo:<tag> pins one.
It runs as a non-root user, listens on port 8080, reads the descriptor from Alvo__DescriptorPath
(/alvo/descriptor.json when you mount your own), and ships no API key and no administrator password. It also carries
the examples that apply, read-only under /alvo/examples/. It runs without ICU, so culture-sensitive comparison and
formatting use the invariant culture.
To build the same image from source instead, from the repository root, because the build needs the root’s package and build settings:
git clone https://github.com/Burgyn/MMLib.Alvo && cd MMLib.Alvodocker build -f src/MMLib.Alvo.Host/Dockerfile -t alvo:local .Then set ALVO_IMAGE=alvo:local for the compose commands below.
2. Choose the database
Section titled “2. Choose the database”The host registers exactly one database driver, chosen by name with
Alvo:Database:Provider. An unknown name stops the host rather than
falling back to a default.
| PostgreSQL | SQLite | |
|---|---|---|
| Provider name | postgresql | sqlite, the default |
| Connection string | ConnectionStrings__Alvo; without one the host refuses to start | optional: ConnectionStrings__Alvo, else Alvo__Database__SqliteConnectionString; the default is Data Source=/alvo/data/alvo.db, a file inside the container |
| Published latency numbers | measured on it | not measured |
Use PostgreSQL. The SQLite default exists so a first docker run needs nothing, and its file is lost with the
container unless you mount /alvo/data. That is also why a PostgreSQL host with no connection string is refused: the
alternative would be writing your rows to a file that disappears. The quick start’s docker-compose.quickstart.yml
already runs the host on PostgreSQL 16; its database password is a demo value, so give your own deployment a real one.
3. Keep every secret in a file
Section titled “3. Keep every secret in a file”Three kinds of secret reach the host, and two of them are accepted only as the path of a mounted file. Passing the
value itself in an environment variable is refused at startup, because an environment variable shows up in a process
listing, in a crash dump and in docker inspect.
- The dashboard’s first administrator:
Alvo__Admin__BootstrapEmail, and the password file inAlvo__Admin__BootstrapPasswordFile.Alvo__Admin__BootstrapPasswordis refused. - The key the database-backed secret store encrypts with, 32 bytes in base64: the file in
Alvo__Secrets__EncryptionKeyFile.Alvo__Secrets__EncryptionKeyis refused. - Each named secret, such as the AI connection’s key:
Alvo__Secrets__Values__<name>, from any configuration source the host reads.
A secret in configuration wins over one saved in the database, and saving one under a name configuration already carries is refused. Without an encryption key file there is no writable store at all rather than a fallback key, so nothing can be saved from the dashboard. The bootstrap administrator is created once: a changed password file does not change an existing account’s password. The image runs as a non-root user, so the files must be readable by it.
The quick start’s compose file already hands the bootstrap administrator’s password over as a mounted file, from
ALVO_ADMIN_PASSWORD. This override puts the rest of the production settings on top of it, the encryption key among
them:
# Saved as docker-compose.production.yml next to docker-compose.quickstart.yml, which already runs the host on# PostgreSQL and hands the bootstrap administrator's password over as a mounted file. Every secret this adds is a# mounted file too, never a value in the environment.name: alvo-production
services: alvo: environment: # Refuse to start on a drifted schema; apply descriptor changes from one place. Alvo__Schema__Startup: Verify # The key the database-backed secret store encrypts with: 32 bytes, base64. Alvo__Secrets__EncryptionKeyFile: /run/secrets/alvo_encryption_key # Publishing the API's shape is your call: false removes /scalar and /openapi/v1.json. Alvo__Docs__Enabled: "false" secrets: - alvo_encryption_key volumes: # The data-protection key ring lives in the app user's home: keep it, and sessions and # set-password links survive a recreated container. - alvo-home:/home/app
secrets: alvo_encryption_key: file: ./.alvo-encryption-key
volumes: alvo-home:4. Set the startup mode
Section titled “4. Set the startup mode”Alvo:Schema:Startup decides what a boot does when the mounted
descriptor no longer matches the schema in the database. The default is Apply, which suits editing a descriptor and
restarting. Production should set Verify, as the override does, and apply descriptor changes from one place: a single
migration job, the Management API or the dashboard.
| Mode | When the descriptor has drifted | What it costs |
|---|---|---|
Apply (default) | applies the plan | every replica of a rolling deploy attempts the change, and the application needs rights to change its own database’s schema |
Verify | refuses to start, printing the steps it would take and the fix | a descriptor change takes effect only once it is applied from that one place |
Skip | serves the schema as it is | the schema is entirely someone else’s job |
An empty database is initialized in every mode but Skip, so the first start works under Verify. No mode ever
discards data on boot: a plan that drops or narrows something is refused unless
Alvo__Schema__AllowDestructive=true.
The sharper cost of Apply is the rollback. A forward deploy under Apply changes the schema without anyone deciding
to. Redeploying the previous descriptor then plans a drop against that schema, and the drop is refused, so the
rollback cannot start. A host holding a descriptor older than the database’s history starts but reports not ready,
with a log line naming both revisions. Under Verify you applied the forward change on purpose and can plan the way
back.
5. Start it and probe it
Section titled “5. Start it and probe it”Make a directory, download the compose file and the override, generate the encryption key file and the two secrets,
then start the stack with both files. COMPOSE_FILE names them, so every compose command in this shell uses both:
mkdir -p alvo-production && cd alvo-productioncurl -fsSLO https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/docker-compose.quickstart.ymlcurl -fsSL -o docker-compose.production.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/production.compose.override.ymlopenssl rand -base64 32 > .alvo-encryption-keyexport COMPOSE_FILE=docker-compose.quickstart.yml:docker-compose.production.ymlexport ALVO_DEMO_KEY_SECRET="$(openssl rand -hex 16)" ALVO_ADMIN_PASSWORD="$(openssl rand -hex 12)"docker compose up --wait --wait-timeout 90--wait returns once the stack’s health check passes, and that check asks readiness. Then ask both probes yourself:
curl -sS -o /dev/null -w 'live: %{http_code}\n' localhost:8080/health/livecurl -sS -o /dev/null -w 'ready: %{http_code}\n' localhost:8080/health/readyBoth answer 200. They are configured oppositely on purpose:
| Probe | Answers | Use it for |
|---|---|---|
/health/live | 200 for any process that is up; it checks nothing at all | the liveness probe: a failure restarts the container |
/health/ready | 200 once the descriptor applied and while the database answers within two seconds; 503 otherwise | the readiness probe and the compose health check: a failure only takes the instance out of traffic |
A database outage therefore drains the instance and never restarts it in a loop. The readiness body is the bare phase word in plain text; the reason for a failure goes to the log, never to this unauthenticated route. Readiness opens a database connection per request, so keep the probe port away from untrusted callers (#183).
When you are done, stop the stack and delete its volumes and the generated key file, in the same shell:
docker compose down --volumesrm .alvo-encryption-keyunset COMPOSE_FILE6. Put it behind a reverse proxy
Section titled “6. Put it behind a reverse proxy”Two settings, both off by default:
Alvo:PathBaseserves the whole application under a prefix, for a proxy that does not strip it. The dashboard always lives at/adminunder that base; it has no mount point of its own.Alvo:ForwardedHeaders:EnabledhonoursX-Forwarded-For,-Proto,-Hostand-Prefix. A created row’sLocationheader is built from them. With it on, the host trusts those headers from anyone, because a container cannot know its proxy’s address. The host must then be reachable only through the proxy: a client that reaches it directly chooses the URL your next client is sent to, and its own rate-limit partition on the dashboard’s sign-in form.
ASP.NET Core’s own ASPNETCORE_FORWARDEDHEADERS_ENABLED does not grant this trust; only the Alvo setting does. The
API browser at /scalar behind a path base has not been verified
(#134).
7. Decide what to expose
Section titled “7. Decide what to expose”- The API’s shape.
/scalarand/openapi/v1.jsonare anonymous. They publish which entities exist and their non-hidden fields, never data.Alvo:Docs:Enabledset tofalseremoves both routes, as the override does. - The Management API at
/managementis closed by default: until the descriptor’saccessblock grants a level, only the bootstrap administrator gets in. Nothing logs or rate-limits it, so rate-limit it at the proxy (or, in an embedded host, on the routes you map). - The dashboard is on by default.
Alvo__Admin__Dashboard__Enabled=falseremoves it, together with its sign-in and set-password endpoints. Both forms are rate-limited per subject and per client; the keys and defaults are in Configuration keys. Behind a proxy without forwarded headers, every client shares the proxy’s one budget.
Run more than one instance
Section titled “Run more than one instance”- One instance drains the outbox. Per-row event order holds only while a single process delivers events (and no two
events for one row are written by different processes in the same millisecond), and no lock enforces it: run one instance with
Alvo:Events:Enabledon and the rest off. Switching it off stops delivery, never the recording of events. - Keep the data-protection keys. Dashboard sessions and set-password links are protected with keys the host keeps
in its user’s home directory,
/home/app. A recreated container loses them, and every session and outstanding link with them; a second instance cannot read the first one’s. That fails closed: people sign in again or ask for a new link. The override mounts a volume at/home/app, so the keys survive a recreated container. They are stored there unencrypted, so protect the volume. - Replicas racing the same schema change converge rather than crash, but
Verifywith one writer is the shape that never races at all.
Measured performance
Section titled “Measured performance”The published numbers were measured over PostgreSQL 16 with 200 000 rows, on one laptop with the load generator on the
same machine: about 5 ms p95 to read one row, about 16 ms for a filtered, sorted list on an indexed column, and about
12 ms to create a row. The full table, the method and how to reproduce it are in
docs/performance.md. They describe that machine,
not a throughput promise.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 500 | internal | Something Alvo relies on broke while it served a generated route. The answer carries a constant detail, never the exception. | Read the host’s log, which has the exception and its stack trace. | standalone; embedded only with AddAlvoProblemDetails() |
| 503 | — | /health/ready before the descriptor applied, while the database does not answer, or when the host holds a descriptor older than the database’s history. | Read the log: it names the failure, or the two revisions. | every host |
The host refuses to start. A configuration it cannot accept stops it with one sentence per problem on stderr and
exit code 78, so a script can tell “an operator must change something” from a crash. That covers a missing
descriptor, an unknown database provider, a PostgreSQL host with no connection string, a secret passed as a value, a
dev key secret under 32 characters, drift under Verify and a plan that would discard data. A descriptor that fails
validation currently exits with code 139 instead (#340).
The first log lines on PostgreSQL say libgssapi_krb5.so.2 cannot be loaded. The database driver looks for
Kerberos support, which the image does not carry; the host starts and serves normally.
Reference
Section titled “Reference”- Configuration:
Alvo(descriptor, database, path base, forwarded headers, docs),Alvo:Schema,Alvo:Secrets,Alvo:Events, keys read outside an options type, and Limits and budgets. - Problem types:
internal. - Design note: the standalone host.
The admin dashboard: sign in and manage the backend you just deployed.