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.
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 with0.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):
AmazonECSTaskExecutionRolePolicysecretsmanager:GetSecretValueon the two Lumaft secretslogs:CreateLogGroupon 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)
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.
Attach it to the container instance, format it once, and mount it at
/srv/lumaft/data.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/dataAdd the mount to
/etc/fstabso it survives reboots.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.configbefore the agent starts (in user data, or followed by a restart of theecsservice):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-initwrites the password file and the backends file from Secrets Manager into a task-local volume, sets ownership to UID 1000 and mode0600, then exits.lumaftstarts afterlumaft-initsucceeds 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
- Create an internet-facing ALB in the public subnets with the
lumaft-albsecurity group. - 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
- Health check path:
- Add an HTTPS
:443listener with your ACM certificate, forwarding to the target group. - Optionally add an HTTP
:80listener that redirects to HTTPS. - Create a Route 53 alias record for your hostname pointing at the ALB.
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-tasksecurity group, public IP disabled. - Load balancer: the target group from Step 5, container
lumaft, port8080. - 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.
Operate on ECS
- Backups. Stop the service (desired count 0), snapshot the EBS volume or copy
lumaft.dboff 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.