Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

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.

  • Docker with Compose v2, curl and openssl: 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 its demo key (ALVO_DEMO_KEY_SECRET).

The image is published as ghcr.io/burgyn/alvo, for linux/amd64 and linux/arm64:

Terminal
docker pull ghcr.io/burgyn/alvo:edge

edge 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:

Terminal
git clone https://github.com/Burgyn/MMLib.Alvo && cd MMLib.Alvo
docker build -f src/MMLib.Alvo.Host/Dockerfile -t alvo:local .

Then set ALVO_IMAGE=alvo:local for the compose commands below.

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.

PostgreSQLSQLite
Provider namepostgresqlsqlite, the default
Connection stringConnectionStrings__Alvo; without one the host refuses to startoptional: ConnectionStrings__Alvo, else Alvo__Database__SqliteConnectionString; the default is Data Source=/alvo/data/alvo.db, a file inside the container
Published latency numbersmeasured on itnot 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.

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 in Alvo__Admin__BootstrapPasswordFile. Alvo__Admin__BootstrapPassword is refused.
  • The key the database-backed secret store encrypts with, 32 bytes in base64: the file in Alvo__Secrets__EncryptionKeyFile. Alvo__Secrets__EncryptionKey is 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:

docker-compose.production.yml
# 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:

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.

ModeWhen the descriptor has driftedWhat it costs
Apply (default)applies the planevery replica of a rolling deploy attempts the change, and the application needs rights to change its own database’s schema
Verifyrefuses to start, printing the steps it would take and the fixa descriptor change takes effect only once it is applied from that one place
Skipserves the schema as it isthe 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.

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:

Terminal
mkdir -p alvo-production && cd alvo-production
curl -fsSLO https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/docker-compose.quickstart.yml
curl -fsSL -o docker-compose.production.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/production.compose.override.yml
openssl rand -base64 32 > .alvo-encryption-key
export COMPOSE_FILE=docker-compose.quickstart.yml:docker-compose.production.yml
export 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:

Terminal
curl -sS -o /dev/null -w 'live: %{http_code}\n' localhost:8080/health/live
curl -sS -o /dev/null -w 'ready: %{http_code}\n' localhost:8080/health/ready

Both answer 200. They are configured oppositely on purpose:

ProbeAnswersUse it for
/health/live200 for any process that is up; it checks nothing at allthe liveness probe: a failure restarts the container
/health/ready200 once the descriptor applied and while the database answers within two seconds; 503 otherwisethe 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:

Terminal
docker compose down --volumes
rm .alvo-encryption-key
unset COMPOSE_FILE

Two settings, both off by default:

  • Alvo:PathBase serves the whole application under a prefix, for a proxy that does not strip it. The dashboard always lives at /admin under that base; it has no mount point of its own.
  • Alvo:ForwardedHeaders:Enabled honours X-Forwarded-For, -Proto, -Host and -Prefix. A created row’s Location header 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).

  • The API’s shape. /scalar and /openapi/v1.json are anonymous. They publish which entities exist and their non-hidden fields, never data. Alvo:Docs:Enabled set to false removes both routes, as the override does.
  • The Management API at /management is closed by default: until the descriptor’s access block 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=false removes 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.
  • 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:Enabled on 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 Verify with one writer is the shape that never races at all.

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.

StatusProblem typeWhenFixReturned by
500internalSomething 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.

The admin dashboard: sign in and manage the backend you just deployed.