Install on a server
Install the Lahijan production stack on a Linux server with Docker Compose, create the first administrator and check that everything is healthy.
This page walks you through a single-server installation of Lahijan with the production compose file, deployments/docker-compose.prod.yml. One docker compose up starts everything the platform needs on the host:
| Service | What it runs |
|---|---|
caddy |
Caddy 2, the public reverse proxy. Terminates TLS with automatic Let's Encrypt certificates and serves the dashboard. |
lahijan |
The Lahijan server (REST API and background workers). |
migrate |
A one-shot golang-migrate job that applies database migrations before lahijan starts. |
postgres |
PostgreSQL 16, shared by Lahijan, PowerDNS and the SeaweedFS filer (three databases). |
powerdns |
PowerDNS Authoritative, serving your users' DNS zones on port 53. |
seaweed-master, seaweed-volume, seaweed-filer, seaweed-s3, seaweed-iam-init |
SeaweedFS object storage and its S3 endpoint. |
incus |
The Incus daemon, in a privileged container, which runs your users' containers and virtual machines. |
otel-collector, jaeger, loki, prometheus, grafana |
Telemetry collection and the operator dashboards. |
For how these fit together, see Architecture. For the full reference of the compose file, see Deployment.
Warning
Lahijan is not yet production-ready. Install it on a server you can rebuild, and test backups before you put real data on it.
Before you start
Server
- A Linux host with kernel 5.15 or newer. Ubuntu 22.04 or later and Debian 12 or later are the tested choices. Incus cannot run on macOS or Windows hosts.
- 4 vCPUs, 8 GB of RAM and 50 GB of disk is a comfortable size. A 2 vCPU, 4 GB host works for a small or personal deployment.
- The kernel modules
vhost_vsock,vethandbridge. Most distributions load them by default; check withls /sys/module/vhost_vsock /sys/module/veth /sys/module/bridge. /dev/kvmif your users will create virtual machines. Containers work without it.- A public IP address.
Software
- Docker Engine 24 or newer with the
docker composesubcommand. Check withdocker versionanddocker compose version. - git and openssl.
- Node.js 20 or newer and npm, to build the dashboard (you can also build it on another machine and copy it over).
You do not need to install Incus, PowerDNS, SeaweedFS or PostgreSQL on the host. They all run in containers.
Firewall
Open these ports to the internet:
| Port | Protocol | Used by |
|---|---|---|
| 80 | TCP | Caddy (HTTP, redirects to HTTPS and answers Let's Encrypt challenges) |
| 443 | TCP and UDP | Caddy (HTTPS and HTTP/3) |
| 53 | TCP and UDP | PowerDNS (authoritative DNS for your users' zones) |
| 8333 | TCP | SeaweedFS S3 endpoint (object data). Change it with SEAWEEDFS_S3_HOST_PORT. |
Everything else stays on the internal Docker network.
DNS records
Create these records before the first start, replacing 203.0.113.10 with your server's address:
app.example.com. A 203.0.113.10 ; the dashboard and APIns1.example.com. A 203.0.113.10 ; your nameserversns2.example.com. A 203.0.113.10s3.example.com. A 203.0.113.10 ; optional: S3 endpoint with TLSThe dashboard record must resolve before Caddy starts, or Let's Encrypt cannot issue the certificate. The nameserver names are what your users will delegate their domains to; see TLS and domains.
1. Get the code
sudo git clone https://github.com/avestura/lahijan.git /opt/lahijancd /opt/lahijanThe compose file mounts configuration files, the database migrations and the built dashboard from this checkout, so keep it in place. The rest of this page assumes you work in /opt/lahijan as a user that can write to it and run docker (for example root, or give your user ownership with sudo chown -R "$USER" /opt/lahijan).
2. Create the environment file
All deployment settings and secrets live in deployments/.env.prod, which git ignores. Start from the example:
cp deployments/.env.prod.example deployments/.env.prodchmod 600 deployments/.env.prodEdit it and replace every CHANGEME value. The example file shows how to generate each secret, for instance:
openssl rand -base64 32 # database passwords, LAHIJAN_AUTH_SECRETS_ENCRYPTIONKEYopenssl rand -base64 48 # LAHIJAN_AUTH_SIGNING_KEYopenssl rand -hex 32 # PDNS_API_KEY, SEAWEEDFS_S3_SECRET_KEYopenssl rand -hex 16 # SEAWEEDFS_S3_ACCESS_KEYThe settings you must look at on every installation:
LAHIJAN_PUBLIC_HOST=app.example.comLAHIJAN_PUBLIC_URL=https://app.example.comLAHIJAN_IMAGE_TAG=v0.1.0 LAHIJAN_DNS_NAMESERVERS="ns1.example.com. ns2.example.com."PDNS_DEFAULT_SOA_CONTENT="ns1.example.com. hostmaster.@ 0 10800 3600 604800 3600" LAHIJAN_S3_PUBLIC_URL=http://203.0.113.10:8333 LAHIJAN_BOOTSTRAP_ADMIN_EMAIL=admin@example.comLAHIJAN_BOOTSTRAP_ADMIN_PASSWORD=LAHIJAN_PUBLIC_HOSTis the host name Caddy gets a certificate for.LAHIJAN_PUBLIC_URLis used in email links and in the CORS settings written to every bucket.LAHIJAN_DNS_NAMESERVERSare written asNSrecords into every new zone, andPDNS_DEFAULT_SOA_CONTENTsets theSOArecord. Keep the first nameserver and the SOA primary the same. If you skip these, new zones point at placeholder names.LAHIJAN_S3_PUBLIC_URLis the S3 address users reach, and the address pre-signed URLs are signed for. Use the published port as above, orhttps://s3.example.comif you set up the TLS site described in TLS and domains.LAHIJAN_BOOTSTRAP_ADMIN_EMAILandLAHIJAN_BOOTSTRAP_ADMIN_PASSWORDcreate the first administrator. See step 6.
Set up outgoing email (LAHIJAN_SMTP_*) too: verification and password-reset emails depend on it. Change the Grafana and Jaeger basic-auth hashes from the default admin password.
Every variable is explained in Environment file. Settings that are not in the file can be passed to the lahijan service as LAHIJAN_* environment variables; see Configuration.
Caution
Back up LAHIJAN_AUTH_SIGNING_KEY and LAHIJAN_AUTH_SECRETS_ENCRYPTIONKEY somewhere safe. The backup script does not include them, and a restore without them cannot decrypt stored secrets. Changing the signing key signs every user out.
3. Build the dashboard
Caddy serves the dashboard from web/dist in the checkout. Build it before the first start:
make web-installmake web-buildIf web/dist does not exist when the stack starts, Docker creates an empty directory and the dashboard address returns nothing. Rebuild the dashboard after every upgrade.
4. Get the Lahijan image
The lahijan service runs the image ghcr.io/avestura/lahijan:${LAHIJAN_IMAGE_TAG}. If that tag is published, docker compose pull fetches it. Otherwise build it from the checkout with the same name and tag:
docker build --build-arg LAHIJAN_IMAGE_TAG=v0.1.0 -t ghcr.io/avestura/lahijan:v0.1.0 .Use the value of LAHIJAN_IMAGE_TAG from your environment file in both places. Pin a real version; do not use latest.
5. Start the stack
Every compose command needs the environment file and the compose file. To save typing, define an alias:
alias lahijan-compose='docker compose --env-file /opt/lahijan/deployments/.env.prod -f /opt/lahijan/deployments/docker-compose.prod.yml'lahijan-compose up -dThe first start takes a few minutes: images are downloaded, Postgres creates its databases, the migrate job applies the schema, and Caddy requests its certificate. Services wait for the ones they depend on to become healthy.
Initialize Incus
The Incus daemon starts empty. Initialize it once, after the first up, with the preseed file from the repository (it creates the default bridge, storage pool and profile):
lahijan-compose exec -T incus incus admin init --preseed < deployments/incus/preseed.yamllahijan-compose exec incus incus listThe second command should print an empty table. Incus keeps its state in /var/lib/incus on the host, so this survives restarts and upgrades.
Small hosts and port 53
Two host situations need a compose override file:
- Fewer than 2 vCPUs. The
incusandlahijanservices have a CPU limit of 2.0, and Docker refuses limits above the host's CPU count. - Port 53 already in use. On Ubuntu,
systemd-resolvedlistens on127.0.0.53:53, which can stop Docker from publishing port 53 on all addresses. Publish it on the public address only.
services: incus: deploy: resources: limits: cpus: "1.0" lahijan: deploy: resources: limits: cpus: "1.0" powerdns: ports: !override - "203.0.113.10:53:53/udp" - "203.0.113.10:53:53/tcp"Because the commands name the compose file with -f, Docker does not pick up the override automatically. Add it to the alias: -f /opt/lahijan/deployments/docker-compose.override.yml. The !override tag needs Docker Compose 2.24 or newer.
6. Sign in as the bootstrap admin
On its first start against an empty database, the Lahijan server creates a default tenant and a user with the platform administrator role. This is controlled by the bootstrap.* settings:
| Setting | Variable in .env.prod |
Default | Meaning |
|---|---|---|---|
bootstrap.enabled |
(set to true by the compose file) |
true |
Turns the first-run bootstrap on or off. |
bootstrap.adminEmail |
LAHIJAN_BOOTSTRAP_ADMIN_EMAIL |
empty | Email of the admin account. Empty skips the bootstrap. |
bootstrap.adminPassword |
LAHIJAN_BOOTSTRAP_ADMIN_PASSWORD |
empty | The admin's password. Empty generates a random one. |
bootstrap.adminDisplayName |
none | Platform Administrator |
Display name of the admin account. |
bootstrap.generatedPasswordLength |
none | 24 |
Length of the generated password. |
The compose file passes the two .env.prod variables to the server as LAHIJAN_BOOTSTRAP_ADMINEMAIL and LAHIJAN_BOOTSTRAP_ADMINPASSWORD.
If you left the password empty, read the generated one from the server log. It is printed once:
lahijan-compose logs lahijan | grep "first-run admin credentials"The bootstrap runs only while the users table is empty. Once any account exists it is skipped on every start, so changing these variables later has no effect.
Open https://app.example.com, sign in with the admin email and password, and change the password right away under Settings > Profile > Change password. Then continue with First steps.
Tip
> scripts/install.sh automates steps 1, 2 and 5: it checks the prerequisites, clones the repository to /opt/lahijan, asks for the main values and writes .env.prod, pulls the images, starts the stack and prints the bootstrap credentials. It does not build the dashboard or the Lahijan image, and it does not initialize Incus, so do steps 3, 4 and the Incus initialization yourself.
7. Check that it is healthy
List the services and their health:
lahijan-compose psEvery long-running service should show (healthy). The one-shot migrate and seaweed-iam-init jobs are not listed because they have finished; lahijan-compose ps -a shows them with exit code 0.
Check the server through Caddy:
curl https://app.example.com/healthcheck/livenessOKThen check each service from the outside:
- DNS: after you create a zone in the dashboard,
dig @203.0.113.10 example.org SOAshould return the SOA record. - Object storage:
curl -I http://203.0.113.10:8333/statusshould answer. - Operator dashboards: Grafana is at
https://app.example.com/grafana/and Jaeger athttps://app.example.com/jaeger/, both behind basic auth.
If a service does not become healthy, look at its log with lahijan-compose logs <service> and see Troubleshooting. Common first-install problems:
- The dashboard shows a certificate warning: the
Arecord forLAHIJAN_PUBLIC_HOSTdoes not point at the server yet, or port 80 is blocked. Caddy's log shows the ACME error. - The
incusservice never becomes healthy: a kernel module is missing, or the image tag inINCUS_IMAGE_TAGdoes not start on your host. Checklahijan-compose logs incus. - The Instances page says the feature is disabled: the server cannot reach the Incus socket. Check that
incusis healthy and that/var/lib/incus/unix.socketexists on the host.
Next steps
- First steps: create your first resources and add users.
- TLS and domains: an S3 host name with TLS, the ACME contact email and nameserver delegation.
- Sign-in providers and email: SMTP, OAuth, OIDC, SAML and passkeys.
- Backups and Upgrades and migrations.
- Security hardening.