Install with Docker Compose
One machine, one command set. Suitable for evaluation, development, and small production deployments that do not need high availability.
This is a single host: no failover, no zero-downtime upgrade, and the database runs beside the application. For a production workload with availability commitments, use Helm.
Before you start
- A Linux host with Docker Engine 25+ and the Compose v2 plugin.
- A DNS record pointing at the host, and inbound TCP 80 and 443 for automatic TLS — or, for an evaluation without one, Evaluating on one machine.
- An SMTP relay.
- The release bundle, downloaded and verified: Download and verify. Run everything below from the bundle directory. You do not need to create any Docker network by hand.
Install in one command
The bundle ships an installer that does every step below, in order, and stops at the first thing that would leave a half-installed stack behind:
./install.sh
It asks for your hostname, your mail relay and the first administrator, then verifies the release, writes .env, generates the secrets, pulls the images, applies the schema, seeds the document catalog, starts the stack and creates the administrator. At the end it prints your installation ID — the value a production licence is bound to.
Running it again is safe: an existing .env, secrets/, database or administrator is reused, so an interrupted install is resumed rather than restarted.
./install.sh --evaluation # a laptop or lab VM with no public DNS name
./install.sh --ingress=none # you already terminate TLS on your own proxy
./install.sh --non-interactive # answers from the environment; --help lists them
Anything it could not do — a missing cosign, a licence you have not been sent yet — is listed under Not performed at the end and written to install-summary.env. Nothing is skipped silently. docker, the Compose v2 plugin and openssl are required and stop the install if missing.
The rest of this page is what the installer does, step by step, for when you would rather do it yourself or need to know what it changed.
1. Configure
cp .env.example .env
Edit .env. At minimum:
| Variable | Value |
|---|---|
REGISTRY | registry.lutralabs.io/customers/<your-slug> |
BACKEND_TAG, FRONTEND_TAG, WORKER_TAG, POSTGRES_TAG | The release you downloaded, e.g. v0.34.28 — the same value for all four |
SITE_ADDRESS, FRONTEND_URL, PUBLIC_BASE_URL | Your public URL, e.g. https://id.example.gov.si |
SMTP_HOST | Your mail relay. Required unless you set EMAIL_TRANSPORT=resend |
EMAIL_FROM | Sender address for application mail |
AUTH_EMAIL_FROM is optional. It is the sender for account mail: sign-in links, email verification, password reset, email change and invitations. It defaults to EMAIL_FROM; set it only if account mail must come from a different address.
Compose refuses to start with a clear error if a required value is missing, and LutraID refuses to start on a configuration that would not work in production — a localhost address, an unencrypted mail relay — naming what to change.
PUBLIC_BASE_URL becomes the issuer identifier embedded in every credential you issue. Changing it later invalidates credentials already in the field.
2. Generate secrets
./init-secrets.sh
Writes one file per secret into secrets/, mode 0600. Nothing secret ever goes into .env.
If your relay needs authentication, set SMTP_USER in .env and put the password in secrets/smtp_password. An unauthenticated internal relay needs neither. With EMAIL_TRANSPORT=resend, put the API key in secrets/resend_api_key.
Secrets are read once, at process start. Changing a file in secrets/ has no effect until you restart the services that consume it: docker compose restart backend worker outbox-relay status-publisher.
3. Create the database schema
docker compose --profile migration run --rm migrator
docker compose --profile migration run --rm migrator seed-base
The first applies the schema; the second seeds the global document catalog. A fresh install needs both.
4. Start
docker compose up -d --wait
--wait returns once every service reports healthy — usually within a minute — and fails loudly if one does not; docker compose logs <service> says why. With a real domain, Caddy requests a TLS certificate on first start, which needs port 80 reachable from the internet.
5. Create the first administrator
Self-signup requires email verification, so the first account is created directly. Choose a password of at least 8 characters:
printf 'Password: '; stty -echo; read -r PASSWORD; stty echo; echo
printf '%s' "$PASSWORD" | docker compose --profile ops run --rm -T bootstrap-superadmin \
--email you@example.gov.si --password-stdin
unset PASSWORD
The password travels over stdin, so it stays out of your shell history and the process list. On success it prints:
[bootstrap-superadmin] Bootstrapped you@example.gov.si (<id>) role="superadmin".
[bootstrap-superadmin] Installation ID: <uuid> (quote this when requesting a LutraID licence).
Keep the installation ID — a production license is bound to it. Sign in at your SITE_ADDRESS; the account is pre-verified. Running the command again for an existing address promotes that user to superadmin and resets their password.
6. Install the license
Put the token you were sent into secrets/lutraid_license and restart the backend:
tr -d '[:space:]' < license.lic > secrets/lutraid_license
docker compose restart backend
docker compose logs backend | grep -i licens
No license yet? LutraID runs normally for 30 days from first start. Install it when it arrives — by file as above, or through the API with no restart: see License.
Using your own reverse proxy
The bundle ships Caddy, which obtains a Let's Encrypt certificate and does the routing. It is optional. If you already terminate TLS — an F5, an nginx, a corporate ingress — you will not want a second proxy in front of it, and you do not need one.
./install.sh --ingress=none
The installer then leaves Caddy out of the stack and publishes the two application ports for you, because nothing else does:
| Port | Serves |
|---|---|
127.0.0.1:3000 | the web application |
127.0.0.1:3001 | the API |
Loopback is the default because it is correct when your proxy runs on the same
host. If it runs elsewhere, set LUTRAID_APP_BIND=0.0.0.0 before installing —
and keep those ports private with a firewall, since anything that reaches them
bypasses your proxy entirely. LUTRAID_FRONTEND_PORT and LUTRAID_BACKEND_PORT
move them off 3000 and 3001.
Your proxy must then reproduce three rules. They are written into the generated
docker-compose.override.yml and printed at the end of the install, so you do
not have to come back here for them.
Rule 1 is the one that gets missed, and it does not look like a proxy problem. Without the prefix strip the sign-in page loads perfectly and every API call returns 404, which reads as an application bug. If you see that, this is why.
/backend/*→ the API, with the/backendprefix stripped. The browser calls the API same-origin under this prefix./v1/*,/.well-known/*,/status-lists/*,/api/auth/*,/health→ the API, unmodified. Wallets and relying parties fetch these, so the paths must arrive exactly as sent.- Everything else → the web application.
Two things your proxy owns once it is in front: it must present a certificate
valid for the hostname in PUBLIC_BASE_URL — that value is baked into every
credential you issue and into the metadata wallets read — and it must forward
the original Host. A proxy that rewrites it will break the same-origin session
cookies.
To do it by hand instead of through the installer: remove the
COMPOSE_PROFILES=ingress line from .env, publish frontend:3000 and
backend:3001 yourself, and bring the stack up with docker compose up -d.
Evaluating on one machine
For a laptop or a lab VM with no public DNS name, run LutraID over HTTPS on a private name. Caddy signs *.internal names with its own local CA, so there is nothing to set up. In step 1, set:
.env
SITE_ADDRESS=https://lutraid.internal:8443
FRONTEND_URL=https://lutraid.internal:8443
PUBLIC_BASE_URL=https://lutraid.internal:8443
HTTPS_PORT=8443
HTTP_PORT=8080
and make the name resolve on every machine whose browser should reach it:
echo '127.0.0.1 lutraid.internal' | sudo tee -a /etc/hosts # or this host's LAN address
The browser warns once about the certificate; accept it, or trust Caddy's root certificate:
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
Why not http://localhost:8080: LutraID refuses a loopback PUBLIC_BASE_URL — wallets and relying parties could never reach it — and session cookies are always Secure, which browsers drop over plain HTTP. A phone wallet must trust the certificate too, so for wallet testing use a real hostname and let Caddy obtain a Let's Encrypt certificate.
Backups
Back up the database and the lutraid_backend_keys volume together, and restore them together. With the default dev_file key backend, that volume holds your issuer keys and the key that protects stored provider secrets. A database backup taken without it restores to an installation that cannot sign or decrypt anything.
The bundle's README.md covers volume backups, WAL-G continuous archiving and point-in-time restore. Rehearse a restore before you need one.
Troubleshooting
| Symptom | Cause |
|---|---|
required variable BACKEND_TAG is missing a value | Image tags not set in .env. |
Failed to read secret file configured by DATABASE_URL_FILE | ./init-secrets.sh was not run, or secrets/ is unreadable. |
Backend exits naming PUBLIC_BASE_URL or FRONTEND_URL and "localhost" | Use your real hostname, or the .internal recipe above. |
Stack will not start, log names SMTP_HOST | EMAIL_TRANSPORT is smtp (the default) and no relay is configured. |
| Certificate never issues | Port 80 not reachable from the internet, or DNS does not point here. Check docker compose logs caddy. |
Browser: "connection is not private" on a .internal name | Expected with Caddy's local CA — accept it, or trust its root certificate. |
| Sign-in page loads, API calls 404 | A reverse proxy in front is not stripping the /backend prefix. See Using your own reverse proxy. |
| No verification emails | The relay rejected the message or will not relay for the sender (AUTH_EMAIL_FROM for account mail and invitations, otherwise EMAIL_FROM). docker compose logs worker outbox-relay has the reason. |
For a support request, collect docker compose ps, docker compose logs --tail 200 backend worker outbox-relay and docker compose config — the last contains no secret values.