Skip to content
Lumaft documentation contents
Lumaft documentation

Set up on AWS ECS

Run Lumaft as an ECS service behind an Application Load Balancer.

Set up on AWS ECS

This guide deploys Lumaft as a single-task ECS service in private subnets, with an Application Load Balancer handling inbound HTTPS and a NAT Gateway handling outbound calls to S3.

Architecture

                       Internet
                          │
            ┌─────────────┴──────────────┐
            │      Internet Gateway      │
            └──────┬──────────────┬──────┘
        inbound    │              │   outbound
                   ▼              ▲
   ┌──────────────────────┐  ┌────┴──────────────┐      public subnets
   │ Application Load     │  │   NAT Gateway     │
   │ Balancer  :443       │  │                   │
   └──────────┬───────────┘  └────▲──────────────┘
              │ :8080              │ 0.0.0.0/0
   ───────────┼────────────────────┼───────────────────  private subnets
              ▼                    │
   ┌──────────────────────────────┴────┐
   │  ECS task: lumaft                 │──── S3 API (state bucket)
   │  /data ← EBS volume (EC2 launch)  │──── ghcr.io (image pull)
   │  or PostgreSQL (Fargate)          │──── licensing service (connected licenses)
   └───────────────────────────────────┘
  • Inbound traffic reaches the ALB through the Internet Gateway. The ALB terminates TLS and forwards plain HTTP to the task on port 8080. Only the ALB's security group may reach the task.
  • Outbound traffic from the task — S3 reads, image pulls, licensing renewals, IdP discovery — leaves the private subnet through the NAT Gateway, which forwards it through the Internet Gateway. The task has no public IP.
ScreenshotAWS VPC resource map showing two public subnets containing the ALB and NAT Gateway, two private subnets containing the ECS task, and route tables pointing private traffic at the NAT Gateway

Choose a launch type

Launch type Database Why
EC2 SQLite (Community) The task bind-mounts an EBS volume attached to the container instance, which satisfies the exclusive block-storage contract
Fargate PostgreSQL (Business) Fargate ephemeral storage and ECS-managed EBS volumes are deleted with the task; SQLite is unsupported there

Do not mount EFS at /data. SQLite's WAL mode requires host-local locking semantics that network filesystems do not provide.

Before you begin

If you would rather not build this by hand, Deploy with Pulumi creates everything on this page — VPC, gateways, roles, volume, cluster, service, and load balancer — from one program. The steps below explain what that program does and let you fit Lumaft into infrastructure you already have.

You need:

  • A VPC with at least two public subnets (ALB, NAT Gateway) and two private subnets (tasks).
  • An Internet Gateway attached to the VPC, a public route table with 0.0.0.0/0 → igw-…, and a private route table with 0.0.0.0/0 → nat-….
  • An ACM certificate for the hostname you will serve Lumaft on.
  • The verified image digest from the release record. See Deployment and setup. The image is public; pulling it needs no registry credential.
  • Your backend details — bucket, prefix, region, layout. See Find your backend details.
  • Two secrets in AWS Secrets Manager, each stored as a plaintext string, not as key/value pairs (the console's key/value editor wraps the value in another JSON object, which breaks the file the init container writes):
    • lumaft/admin-password — the bootstrap administrator password.
    • lumaft/backends — the backends JSON document from Connect a backend.
aws secretsmanager create-secret --name lumaft/admin-password \
  --secret-string "$(openssl rand -base64 24)"
aws secretsmanager create-secret --name lumaft/backends \
  --secret-string file://backends.json

Step 1: Security groups

Create two security groups.

lumaft-alb

Direction Protocol Port Source / destination
Inbound TCP 443 Your allowed CIDR ranges
Outbound TCP 8080 lumaft-task

lumaft-task

Direction Protocol Port Source / destination
Inbound TCP 8080 lumaft-alb only
Outbound TCP 443 0.0.0.0/0 (via NAT)

For the EC2 launch type, attach lumaft-task to the container instance as well, and allow the ECS agent's outbound HTTPS.

Step 2: IAM roles

ECS uses two roles. If you have not created IAM roles before, IAM setup on AWS walks through both with the exact CLI commands and trust policies.

Task execution role (used by ECS to start the task):

  • AmazonECSTaskExecutionRolePolicy
  • secretsmanager:GetSecretValue on the two Lumaft secrets
  • logs:CreateLogGroup on the log group, so the first task can create it

Task role (used by Lumaft at runtime): read-only access to the state namespace.

{
  "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/*"
    }
  ]
}

Add kms:Decrypt on the specific key when the bucket uses SSE-KMS. Never add a mutation action to this role. The full policy variants, including the prefix-scoped form for other engines, are in Connect a backend.

Step 3: Storage

EC2 launch type (SQLite)

  1. Create a gp3 EBS volume in the availability zone of your container instance. Enable encryption. Keep the volume's lifecycle independent of the instance and the service.

  2. Attach it to the container instance, format it once, and mount it at /srv/lumaft/data.

  3. Set ownership and mode so UID 1000 owns it exclusively:

    sudo mkdir -p /srv/lumaft/data
    sudo mount /dev/nvme1n1 /srv/lumaft/data
    sudo chown 1000:1000 /srv/lumaft/data
    sudo chmod 700 /srv/lumaft/data
    
  4. Add the mount to /etc/fstab so it survives reboots.

  5. Register a custom ECS attribute on the instance so a placement constraint can pin the task to it. On the ECS-optimized AMI, add this line to /etc/ecs/ecs.config before the agent starts (in user data, or followed by a restart of the ecs service):

    ECS_INSTANCE_ATTRIBUTES={"lumaft.durable-volume":"true"}
    

Run one container instance per Lumaft installation. A second task on the same volume fails closed with ownership-conflict.

Fargate (PostgreSQL)

Provision Amazon RDS for PostgreSQL in the private subnets, with a dedicated database and a role that can manage Lumaft's schema. Store the connection URL in Secrets Manager. Lumaft reads it from a file, so the init container below writes it to /run/lumaft/postgres-url. See Database integration for the connection requirements.

Step 4: Task definition

The task runs two containers:

  • lumaft-init writes the password file and the backends file from Secrets Manager into a task-local volume, sets ownership to UID 1000 and mode 0600, then exits.
  • lumaft starts after lumaft-init succeeds and mounts that volume read-only at /run/lumaft.
{
  "family": "lumaft",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["EC2"],
  "cpu": "1024",
  "memory": "2048",
  "executionRoleArn": "arn:aws:iam::123456789012:role/lumaft-execution",
  "taskRoleArn": "arn:aws:iam::123456789012:role/lumaft-task",
  "placementConstraints": [
    { "type": "memberOf", "expression": "attribute:lumaft.durable-volume == true" }
  ],
  "volumes": [
    { "name": "lumaft-data", "host": { "sourcePath": "/srv/lumaft/data" } },
    { "name": "lumaft-runtime" },
    { "name": "lumaft-tmp" }
  ],
  "containerDefinitions": [
    {
      "name": "lumaft-init",
      "image": "ghcr.io/dekglas/lumaft@sha256:<digest>",
      "essential": false,
      "user": "0",
      "readonlyRootFilesystem": true,
      "entryPoint": ["/bin/sh", "-ec"],
      "command": [
        "umask 077 && install -d -o 1000 -g 1000 -m 0700 /run/lumaft /tmp && printf '%s\\n' \"$BOOTSTRAP_PASSWORD\" > /run/lumaft/admin-password && printf '%s\\n' \"$BACKENDS_JSON\" > /run/lumaft/backends.json && chown 1000:1000 /run/lumaft/* && chmod 0600 /run/lumaft/*"
      ],
      "secrets": [
        {
          "name": "BOOTSTRAP_PASSWORD",
          "valueFrom": "arn:aws:secretsmanager:...:secret:lumaft/admin-password"
        },
        {
          "name": "BACKENDS_JSON",
          "valueFrom": "arn:aws:secretsmanager:...:secret:lumaft/backends"
        }
      ],
      "mountPoints": [
        { "sourceVolume": "lumaft-runtime", "containerPath": "/run/lumaft" },
        { "sourceVolume": "lumaft-tmp", "containerPath": "/tmp" }
      ]
    },
    {
      "name": "lumaft",
      "image": "ghcr.io/dekglas/lumaft@sha256:<digest>",
      "essential": true,
      "readonlyRootFilesystem": true,
      "dependsOn": [{ "containerName": "lumaft-init", "condition": "SUCCESS" }],
      "portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
      "environment": [
        { "name": "LUMAFT_LOCAL_ADMIN_PASSWORD_FILE", "value": "/run/lumaft/admin-password" },
        { "name": "LUMAFT_BACKENDS_FILE", "value": "/run/lumaft/backends.json" },
        { "name": "LUMAFT_PUBLIC_ORIGIN", "value": "https://lumaft.example.com" }
      ],
      "mountPoints": [
        { "sourceVolume": "lumaft-data", "containerPath": "/data" },
        { "sourceVolume": "lumaft-runtime", "containerPath": "/run/lumaft", "readOnly": true },
        { "sourceVolume": "lumaft-tmp", "containerPath": "/tmp" }
      ],
      "healthCheck": {
        "command": ["CMD", "node", "/app/container-healthcheck.mjs"],
        "interval": 30,
        "timeout": 3,
        "retries": 3,
        "startPeriod": 10
      },
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/lumaft/application",
          "awslogs-create-group": "true",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "lumaft"
        }
      }
    }
  ]
}

LUMAFT_PUBLIC_ORIGIN is the HTTPS address browsers use. It matters only for OIDC and SAML sign-in (Enterprise), but setting it now costs nothing and saves a redeploy later.

For Fargate, change requiresCompatibilities to ["FARGATE"], remove placementConstraints and the lumaft-data volume, add a POSTGRES_URL secret to the init container that it writes to /run/lumaft/postgres-url, and set LUMAFT_POSTGRES_URL_FILE=/run/lumaft/postgres-url on the lumaft container.

After the first successful sign-in, remove LUMAFT_LOCAL_ADMIN_PASSWORD_FILE and the BOOTSTRAP_PASSWORD secret from the task definition and register a new revision. The file creates the first administrator only; leaving it in place does not rotate anything, but it keeps a credential in circulation that no longer needs to exist.

Step 5: Application Load Balancer

  1. Create an internet-facing ALB in the public subnets with the lumaft-alb security group.
  2. Create a target group: target type IP, protocol HTTP, port 8080.
    • Health check path: /api/v1/readiness
    • Success codes: 200
    • Interval 30 s, timeout 5 s, healthy threshold 2, unhealthy threshold 3
  3. Add an HTTPS :443 listener with your ACM certificate, forwarding to the target group.
  4. Optionally add an HTTP :80 listener that redirects to HTTPS.
  5. Create a Route 53 alias record for your hostname pointing at the ALB.
ScreenshotEC2 console target group configuration page showing target type IP, HTTP on port 8080, and health check path /api/v1/readiness

The ALB provides the HTTPS origin browsers need for the Secure __Host-lumaft-session cookie. Leave LUMAFT_ALLOW_HTTP unset.

Step 6: ECS service

Create the service with:

  • Desired count 1.
  • Deployment configuration minimum healthy 0 %, maximum 100 % on the EC2 launch type, so a rollout stops the old task before starting the new one. Two tasks cannot share the SQLite volume; the second refuses to start.
  • Deployment circuit breaker enabled with rollback.
  • Network: private subnets, lumaft-task security group, public IP disabled.
  • Load balancer: the target group from Step 5, container lumaft, port 8080.
  • Health check grace period 60 seconds.

On Fargate with PostgreSQL, the same single-task shape applies unless you hold an Enterprise license and configure replicas; see Database integration.

Step 7: Verify

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

Then sign in, open Administration → Backends, and confirm the diagnostic result for each backend. A credential or network failure is isolated to that backend and does not affect readiness.

ScreenshotAdministration → Backends page listing one backend with its diagnostic status, engine, layout, and the time of the last successful observation

Operate on ECS

  • Backups. Stop the service (desired count 0), snapshot the EBS volume or copy lumaft.db off the instance, then restore desired count 1. A snapshot taken while the task is running is crash-consistent, not a verified cold backup.
  • Upgrades. Take a cold backup, register a new task definition revision with the new digest, and update the service. A refused start with a gated migration is expected; follow Database integration.
  • Egress cost. S3 reads through the NAT Gateway incur NAT processing charges. An S3 gateway VPC endpoint in the private route table keeps S3 traffic off the NAT Gateway without changing anything in Lumaft.
  • Logs. Startup failures end with one sanitized line naming the failure class. No paths, SQL, or secrets are logged. Troubleshooting maps each line to its fix.