Install with Docker Compose

One machine, one command set. Suitable for evaluation, development, and small production deployments that do not need high availability.

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

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:

VariableValue
REGISTRYregistry.lutralabs.io/customers/<your-slug>
BACKEND_TAG, FRONTEND_TAG, WORKER_TAG, POSTGRES_TAGThe release you downloaded, e.g. v0.34.28 — the same value for all four
SITE_ADDRESS, FRONTEND_URL, PUBLIC_BASE_URLYour public URL, e.g. https://id.example.gov.si
SMTP_HOSTYour mail relay. Required unless you set EMAIL_TRANSPORT=resend
EMAIL_FROMSender 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.

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.

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:

PortServes
127.0.0.1:3000the web application
127.0.0.1:3001the 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.

  1. /backend/* → the API, with the /backend prefix stripped. The browser calls the API same-origin under this prefix.
  2. /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.
  3. 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

SymptomCause
required variable BACKEND_TAG is missing a valueImage 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_HOSTEMAIL_TRANSPORT is smtp (the default) and no relay is configured.
Certificate never issuesPort 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 nameExpected with Caddy's local CA — accept it, or trust its root certificate.
Sign-in page loads, API calls 404A reverse proxy in front is not stripping the /backend prefix. See Using your own reverse proxy.
No verification emailsThe 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.

Was this page helpful?