Skip to content
Lumaft documentation contents
Lumaft documentation

Set up on AWS EC2

Run Lumaft directly on an EC2 instance with an EBS data volume and a TLS reverse proxy.

Set up on AWS EC2

This guide runs Lumaft as a container on one EC2 instance. The instance profile supplies read-only S3 access, an EBS volume holds the SQLite database, and a reverse proxy on the instance terminates TLS.

It is the simplest AWS deployment and the easiest to reason about: one instance, one volume, one container.

Architecture

   Internet ──443──▶ ┌──────────────────────────────────────┐
                     │  EC2 instance                        │
                     │  ┌──────────┐    ┌────────────────┐  │
                     │  │ Caddy /  │    │ lumaft         │  │──── S3 (state bucket)
                     │  │ nginx    │──▶ │ 127.0.0.1:8080 │  │──── ghcr.io (image pull)
                     │  │ :443     │    │ /data ← EBS    │  │──── licensing service
                     │  └──────────┘    └────────────────┘  │
                     └──────────────────────────────────────┘

Before you begin

Deploy with Pulumi creates the instance, role, volume, and security group from one program if you prefer not to click through the console.

Step 1: Security group

Direction Protocol Port Source / destination Purpose
Inbound TCP 443 Your allowed CIDR ranges Browser and runner traffic
Inbound TCP 80 0.0.0.0/0 (optional) ACME HTTP-01 challenges only
Outbound TCP 443 0.0.0.0/0 S3, registry, licensing, ACME

Do not open port 8080. Lumaft binds to the loopback interface and only the reverse proxy reaches it.

Do not open port 22. Use AWS Systems Manager Session Manager for shell access; it needs no inbound rule.

Step 2: IAM instance profile

Create a role for EC2 with two policies:

  • AmazonSSMManagedInstanceCore for Session Manager.
  • The read-only state policy below.
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListPulumiState",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::BUCKET_NAME",
      "Condition": { "StringLike": { "s3:prefix": ["OPTIONAL_PREFIX/.pulumi/*"] } }
    },
    {
      "Sid": "ReadPulumiState",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::BUCKET_NAME/OPTIONAL_PREFIX/.pulumi/*"
    }
  ]
}

Attach the role as the instance profile. Lumaft picks it up through the AWS SDK credential chain with no configuration. Require IMDSv2 on the instance. The CLI commands that create the policy, the role, and the instance profile are in IAM setup on AWS.

Step 3: Launch the instance

  • AMI: Amazon Linux 2023 or Ubuntu LTS.
  • Instance type: start at 2 vCPU / 4 GiB (for example t3.medium or t4g.medium), which leaves room for the proxy beside Lumaft's 1 vCPU / 2 GiB baseline.
  • Root volume: the AMI default.
  • Data volume: add a second, encrypted gp3 EBS volume (50 GiB is a comfortable start). Set Delete on termination to No so the database outlives the instance.
  • Subnet: a public subnet with an Elastic IP, or a private subnet behind an ALB (see Alternative: ALB in front).
  • Metadata: IMDSv2 required.

Connect with Session Manager:

aws ssm start-session --target i-0123456789abcdef0

Install Docker

Amazon Linux 2023:

sudo dnf install -y docker
sudo systemctl enable --now docker

Ubuntu:

sudo apt-get update && sudo apt-get install -y docker.io
sudo systemctl enable --now docker

Step 4: Prepare the data volume

Find the data volume's device name first; it is not always nvme1n1:

lsblk

The unformatted device with no mount point and the size you chose is the data volume. Then:

DEVICE=/dev/nvme1n1   # from lsblk
sudo mkfs.ext4 -L lumaft-data "$DEVICE"
sudo mkdir -p /srv/lumaft/data
echo 'LABEL=lumaft-data /srv/lumaft/data ext4 defaults,nofail 0 2' | sudo tee -a /etc/fstab
sudo mount -a
sudo chown 1000:1000 /srv/lumaft/data
sudo chmod 700 /srv/lumaft/data

UID 1000 is the node user inside the container. The directory must be owned by it and not writable by anyone else. mkfs erases the device — run it once, on the new volume only.

Step 5: Prepare the secret files

sudo mkdir -p /srv/lumaft/run
sudo chmod 700 /srv/lumaft/run

# Bootstrap administrator password (12–4096 characters)
sudo sh -c 'umask 077; printf "%s\n" "$(openssl rand -base64 24)" > /srv/lumaft/run/admin-password'

# Backend definitions — see Connect a backend
sudo sh -c 'umask 077; cat > /srv/lumaft/run/backends.json' <<'JSON'
{
  "schemaVersion": 1,
  "backends": [
    {
      "kind": "s3",
      "id": "production",
      "displayName": "Production infrastructure",
      "bucket": "BUCKET_NAME",
      "prefix": "OPTIONAL_PREFIX",
      "layout": "project-scoped",
      "region": "us-east-1",
      "forcePathStyle": false,
      "enabled": true
    }
  ]
}
JSON

sudo chown -R 1000:1000 /srv/lumaft/run
sudo chmod 600 /srv/lumaft/run/*

Record the generated password somewhere protected. You need it once, to sign in and create your own account.

Step 6: Run Lumaft

LUMAFT_IMAGE="ghcr.io/dekglas/lumaft@sha256:<digest>"

sudo docker run --name lumaft \
  --detach \
  --restart unless-stopped \
  --init \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --publish 127.0.0.1:8080:8080 \
  --mount type=bind,source=/srv/lumaft/data,target=/data \
  --mount type=bind,source=/srv/lumaft/run,target=/run/lumaft,readonly \
  --env LUMAFT_LOCAL_ADMIN_PASSWORD_FILE=/run/lumaft/admin-password \
  --env LUMAFT_BACKENDS_FILE=/run/lumaft/backends.json \
  --env LUMAFT_PUBLIC_ORIGIN=https://lumaft.example.com \
  "$LUMAFT_IMAGE"

curl -fsS http://127.0.0.1:8080/api/v1/readiness

--publish 127.0.0.1:8080:8080 is deliberate. The container is reachable only from the instance itself.

Step 7: Terminate TLS

Point your DNS record at the instance's Elastic IP first; the certificate authority must be able to reach the hostname.

Caddy obtains and renews a certificate automatically. Running it as a container keeps the instance free of extra packages:

sudo mkdir -p /srv/lumaft/caddy
sudo tee /srv/lumaft/caddy/Caddyfile >/dev/null <<'EOF'
lumaft.example.com {
    reverse_proxy 127.0.0.1:8080
}
EOF

sudo docker run --name caddy \
  --detach \
  --restart unless-stopped \
  --network host \
  --mount type=bind,source=/srv/lumaft/caddy/Caddyfile,target=/etc/caddy/Caddyfile,readonly \
  --mount type=volume,source=caddy-data,target=/data \
  caddy:2

--network host lets Caddy listen on 443 and reach Lumaft on 127.0.0.1:8080. The caddy-data volume keeps the certificate across restarts. Replace lumaft.example.com with your hostname; that line is the only configuration Caddy needs.

With nginx, terminate TLS on 443 and proxy_pass http://127.0.0.1:8080; with proxy_set_header Host $host;. Either way, browsers only ever connect over HTTPS, so the default Secure __Host-lumaft-session cookie works. Leave LUMAFT_ALLOW_HTTP unset.

Verify from your workstation:

curl -fsS https://lumaft.example.com/api/v1/readiness

The first request can take up to a minute while Caddy obtains the certificate.

Step 8: First sign-in and cleanup

  1. Open https://lumaft.example.com and sign in as admin with the generated password.
  2. Open Administration → Backends and confirm the backend diagnostic succeeded.
  3. Stop the container, remove --env LUMAFT_LOCAL_ADMIN_PASSWORD_FILE=… from the run command, securely delete /srv/lumaft/run/admin-password, and start it again.
ScreenshotOverview page after first sign-in, showing the observed estate summary with one backend, its project and stack counts, and the last observation time

Alternative: ALB in front

If you prefer an ALB to terminate TLS:

  • Place the instance in a private subnet with a NAT Gateway route for outbound S3 and registry traffic.
  • Publish the container on the instance's private address instead of loopback (--publish 10.0.1.20:8080:8080) and restrict the security group so port 8080 admits only the ALB's security group.
  • Register the instance in a target group (target type Instance, HTTP, port 8080, health check /api/v1/readiness).

The ALB then provides the HTTPS origin and no proxy runs on the instance.

Operate on EC2

Backups

Backups are cold. Stop the container, copy the file, start it again:

sudo mkdir -p /srv/lumaft/backups && sudo chmod 700 /srv/lumaft/backups
sudo docker stop -t 30 lumaft
sudo cp /srv/lumaft/data/lumaft.db /srv/lumaft/backups/lumaft-$(date -u +%Y%m%dT%H%M%SZ).db
sudo docker start lumaft

Docker Compose and systemd packages this instance's run command, restart-on-boot, and a backup script so you do not have to retype them.

Copy backups off the instance (for example to a separate, versioned S3 bucket with its own access policy). A crash-consistent EBS snapshot is a useful second layer but is not a verified cold backup.

Restore

  1. sudo docker stop -t 30 lumaft && sudo docker rm lumaft
  2. Replace /srv/lumaft/data/lumaft.db with the backup and delete any leftover lumaft.db-wal, lumaft.db-shm, and lumaft.db.owner files.
  3. Run the container again and confirm readiness.

Upgrades

  1. Stop the container cleanly and take a cold backup.
  2. Run the same command with the new digest against the same /srv/lumaft/data.
  3. Confirm readiness. If startup refuses because the upgrade carries a gated migration, nothing has been migrated; see Database integration.

Monitoring

Poll https://lumaft.example.com/api/v1/readiness from your monitoring system. For storage pressure, poll the authenticated /api/v1/storage-health endpoint with an administrator session and alert on writeProbe.outcome == "failed" or low dataDirectory.freeBytes.

If something does not work, Troubleshooting is organized by symptom.