Deploy with Pulumi
You already run Pulumi, so the least surprising way to deploy Lumaft is a Pulumi program. This page gives you two complete ones:
| Program | Creates | Monthly cost drivers |
|---|---|---|
| EC2 | One instance in your default VPC, an EBS volume, an instance profile, Caddy for TLS, Secrets Manager | The instance and the volume |
| ECS on EC2 | A VPC with public and private subnets, NAT Gateway, ALB with an ACM certificate, ECS cluster and service, EBS volume, roles, secrets | Instance, volume, NAT Gateway, ALB |
Both programs follow the environment guides exactly — Set up on AWS EC2
and Set up on AWS ECS explain every resource they create — and
both keep the SQLite database on an EBS volume that is protected from accidental deletion.
The programs typecheck against @pulumi/pulumi 3.263, @pulumi/aws 7.46, and @pulumi/awsx
3.9. They are reference programs: read them before you run them, and expect to adapt names,
CIDRs, and instance sizes to your account.
Before you begin
- The Pulumi CLI and Node.js 22 or later.
- AWS credentials with permission to create the resources listed above.
- A Pulumi backend for this program's own state — the same DIY bucket you will point Lumaft at works fine. Lumaft can observe its own deployment stack.
- The image digest from the release record. See Deployment and setup.
- Your backend details from Find your backend details.
- For the ECS program, an ACM certificate for your hostname in the deployment region.
Program A: EC2
Files
# Pulumi.yaml
name: lumaft-ec2
runtime: nodejs
description: Lumaft on one EC2 instance with an EBS data volume
{
"name": "lumaft-ec2",
"private": true,
"main": "index.ts",
"devDependencies": { "@types/node": "^24", "typescript": "^5.9" },
"dependencies": { "@pulumi/aws": "^7.46", "@pulumi/pulumi": "^3.263" }
}
// index.ts
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
// Lumaft on one EC2 instance: an EBS data volume for SQLite, an instance profile with the
// read-only state policy, and Caddy terminating TLS on the instance. Runs in the account's
// default VPC so there is nothing to build before the first `pulumi up`.
const config = new pulumi.Config();
const hostname = config.require('hostname'); // e.g. lumaft.example.com
const imageDigest = config.require('imageDigest'); // sha256:… from the release record
const stateBucket = config.require('stateBucket');
const statePrefix = config.get('statePrefix') ?? ''; // no leading or trailing slash
const stateRegion = config.get('stateRegion') ?? aws.config.region ?? 'us-east-1';
const layout = config.get('layout') ?? 'project-scoped'; // or "legacy"
const kmsKeyArn = config.get('kmsKeyArn'); // only for SSE-KMS buckets
const hostedZoneId = config.get('hostedZoneId'); // omit to manage DNS yourself
const allowedCidrs = config.getObject<string[]>('allowedCidrs') ?? ['0.0.0.0/0'];
const instanceType = config.get('instanceType') ?? 't3.medium';
const dataVolumeGib = config.getNumber('dataVolumeGib') ?? 50;
const adminPassword = config.requireSecret('adminPassword'); // 12–4096 characters
const image = `ghcr.io/dekglas/lumaft@${imageDigest}`;
const prefixSegment = statePrefix === '' ? '' : `${statePrefix}/`;
// --- Network: the default VPC's first subnet -------------------------------------------------
const vpc = aws.ec2.getVpcOutput({ default: true });
const subnets = aws.ec2.getSubnetsOutput({
filters: [
{ name: 'vpc-id', values: [vpc.id] },
{ name: 'default-for-az', values: ['true'] },
],
});
const subnetId = subnets.ids.apply((ids) => {
const first = ids[0];
if (first === undefined) throw new Error('The default VPC has no default subnets.');
return first;
});
const subnet = aws.ec2.getSubnetOutput({ id: subnetId });
const securityGroup = new aws.ec2.SecurityGroup('lumaft', {
vpcId: vpc.id,
description: 'Lumaft: HTTPS in, HTTPS out',
ingress: [
{ protocol: 'tcp', fromPort: 443, toPort: 443, cidrBlocks: allowedCidrs },
// Only for the certificate authority's HTTP-01 validation and the HTTPS redirect.
{ protocol: 'tcp', fromPort: 80, toPort: 80, cidrBlocks: ['0.0.0.0/0'] },
],
egress: [{ protocol: 'tcp', fromPort: 443, toPort: 443, cidrBlocks: ['0.0.0.0/0'] }],
});
// --- Secrets: never in user data --------------------------------------------------------------
const backendsDocument = JSON.stringify({
schemaVersion: 1,
backends: [
{
kind: 's3',
id: 'production',
displayName: 'Production infrastructure',
bucket: stateBucket,
...(statePrefix === '' ? {} : { prefix: statePrefix }),
layout,
region: stateRegion,
forcePathStyle: false,
enabled: true,
},
],
});
const adminPasswordSecret = new aws.secretsmanager.Secret('lumaft-admin-password', {
recoveryWindowInDays: 7,
});
new aws.secretsmanager.SecretVersion('lumaft-admin-password', {
secretId: adminPasswordSecret.id,
secretString: adminPassword,
});
const backendsSecret = new aws.secretsmanager.Secret('lumaft-backends', {
recoveryWindowInDays: 7,
});
new aws.secretsmanager.SecretVersion('lumaft-backends', {
secretId: backendsSecret.id,
secretString: backendsDocument,
});
// --- IAM: read-only state access, Session Manager, and the two secrets -----------------------
// Only SSE-KMS buckets need the decrypt grant; see Find your backend details.
const kmsStatements: aws.iam.PolicyStatement[] =
kmsKeyArn === undefined
? []
: [{ Sid: 'DecryptState', Effect: 'Allow', Action: 'kms:Decrypt', Resource: kmsKeyArn }];
const readOnlyState = new aws.iam.Policy('lumaft-read-only-state', {
policy: {
Version: '2012-10-17',
Statement: [
{
Sid: 'ListPulumiState',
Effect: 'Allow',
Action: 's3:ListBucket',
Resource: `arn:aws:s3:::${stateBucket}`,
Condition: { StringLike: { 's3:prefix': [`${prefixSegment}.pulumi/*`] } },
},
{
Sid: 'ReadPulumiState',
Effect: 'Allow',
Action: 's3:GetObject',
Resource: `arn:aws:s3:::${stateBucket}/${prefixSegment}.pulumi/*`,
},
...kmsStatements,
],
},
});
const role = new aws.iam.Role('lumaft-instance', {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: 'ec2.amazonaws.com' }),
});
new aws.iam.RolePolicyAttachment('lumaft-instance-state', {
role: role.name,
policyArn: readOnlyState.arn,
});
new aws.iam.RolePolicyAttachment('lumaft-instance-ssm', {
role: role.name,
policyArn: 'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore',
});
new aws.iam.RolePolicy('lumaft-instance-secrets', {
role: role.id,
policy: pulumi.jsonStringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: 'secretsmanager:GetSecretValue',
Resource: [adminPasswordSecret.arn, backendsSecret.arn],
},
],
}),
});
const instanceProfile = new aws.iam.InstanceProfile('lumaft-instance', { role: role.name });
// --- Storage: the data volume outlives the instance -------------------------------------------
const dataVolume = new aws.ebs.Volume(
'lumaft-data',
{
availabilityZone: subnet.availabilityZone,
size: dataVolumeGib,
type: 'gp3',
encrypted: true,
tags: { Name: 'lumaft-data' },
},
{ protect: true },
);
// --- Instance ---------------------------------------------------------------------------------
const ami = aws.ssm.getParameterOutput({
name: '/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64',
});
const userData = pulumi.interpolate`#!/bin/bash
set -euo pipefail
dnf install -y docker
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL "https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-$(uname -m)" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
systemctl enable --now docker
# Wait for the data volume, format it only if it is blank, and mount it.
VOLUME_ID="${dataVolume.id.apply((id) => id.replace('-', ''))}"
DEVICE="/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_$VOLUME_ID"
for i in $(seq 1 60); do [ -e "$DEVICE" ] && break; sleep 5; done
blkid "$DEVICE" >/dev/null 2>&1 || mkfs.ext4 -L lumaft-data "$DEVICE"
mkdir -p /srv/lumaft/data
grep -q lumaft-data /etc/fstab || echo 'LABEL=lumaft-data /srv/lumaft/data ext4 defaults,nofail 0 2' >> /etc/fstab
mount -a
chown 1000:1000 /srv/lumaft/data && chmod 700 /srv/lumaft/data
# Secret files, fetched with the instance role; nothing sensitive lives in this script.
mkdir -p /srv/lumaft/run /srv/lumaft/caddy /srv/lumaft/backups
chmod 700 /srv/lumaft/run /srv/lumaft/backups
umask 077
aws secretsmanager get-secret-value --secret-id "${adminPasswordSecret.arn}" --query SecretString --output text > /srv/lumaft/run/admin-password
aws secretsmanager get-secret-value --secret-id "${backendsSecret.arn}" --query SecretString --output text > /srv/lumaft/run/backends.json
chown -R 1000:1000 /srv/lumaft/run
cat > /srv/lumaft/.env <<'ENV'
LUMAFT_IMAGE=${image}
LUMAFT_HOSTNAME=${hostname}
ENV
cat > /srv/lumaft/caddy/Caddyfile <<'CADDY'
{$LUMAFT_HOSTNAME} {
reverse_proxy lumaft:8080
}
CADDY
cat > /srv/lumaft/compose.yaml <<'COMPOSE'
services:
lumaft:
image: \${LUMAFT_IMAGE}
restart: unless-stopped
init: true
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=64m"]
ports: ["127.0.0.1:8080:8080"]
volumes:
- /srv/lumaft/data:/data
- /srv/lumaft/run:/run/lumaft:ro
environment:
LUMAFT_LOCAL_ADMIN_PASSWORD_FILE: /run/lumaft/admin-password
LUMAFT_BACKENDS_FILE: /run/lumaft/backends.json
LUMAFT_PUBLIC_ORIGIN: https://\${LUMAFT_HOSTNAME}
logging: { driver: json-file, options: { max-size: 50m, max-file: "5" } }
stop_grace_period: 30s
caddy:
image: caddy:2
restart: unless-stopped
environment: { LUMAFT_HOSTNAME: \${LUMAFT_HOSTNAME} }
ports: ["80:80", "443:443"]
volumes:
- /srv/lumaft/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on: [lumaft]
volumes:
caddy-data:
COMPOSE
cat > /etc/systemd/system/lumaft.service <<'UNIT'
[Unit]
Description=Lumaft
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/lumaft
ExecStart=/usr/bin/docker compose up --detach --remove-orphans
ExecStop=/usr/bin/docker compose stop --timeout 30
TimeoutStopSec=90
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now lumaft.service
`;
const instance = new aws.ec2.Instance('lumaft', {
ami: ami.value,
instanceType,
subnetId,
vpcSecurityGroupIds: [securityGroup.id],
iamInstanceProfile: instanceProfile.name,
metadataOptions: { httpTokens: 'required' },
userData,
userDataReplaceOnChange: false,
rootBlockDevice: { encrypted: true, volumeType: 'gp3' },
tags: { Name: 'lumaft' },
});
new aws.ec2.VolumeAttachment('lumaft-data', {
deviceName: '/dev/sdf',
volumeId: dataVolume.id,
instanceId: instance.id,
stopInstanceBeforeDetaching: true,
});
const eip = new aws.ec2.Eip('lumaft', { instance: instance.id });
if (hostedZoneId !== undefined) {
new aws.route53.Record('lumaft', {
zoneId: hostedZoneId,
name: hostname,
type: 'A',
ttl: 60,
records: [eip.publicIp],
});
}
export const instanceId = instance.id;
export const publicIp = eip.publicIp;
export const url = `https://${hostname}`;
export const adminPasswordSecretArn = adminPasswordSecret.arn;
Configure and deploy
npm install
pulumi stack init production
pulumi config set aws:region us-east-1
pulumi config set hostname lumaft.example.com
pulumi config set imageDigest sha256:REPLACE_WITH_THE_RELEASE_DIGEST
pulumi config set stateBucket acme-pulumi-state
pulumi config set statePrefix team/platform # omit if none
pulumi config set layout project-scoped # or legacy
pulumi config set hostedZoneId Z0123456789ABCDEFGHIJ # omit to manage DNS yourself
pulumi config set --secret adminPassword "$(openssl rand -base64 24)"
pulumi up
Optional settings: stateRegion (defaults to aws:region), kmsKeyArn for an SSE-KMS bucket,
allowedCidrs as a JSON list (defaults to everywhere), instanceType (default t3.medium),
dataVolumeGib (default 50).
pulumi up takes a few minutes. The instance then needs one or two more to install Docker,
attach the volume, fetch the secrets, and obtain a certificate. Then:
curl -fsS "$(pulumi stack output url)/api/v1/readiness"
pulumi config get adminPassword # the bootstrap password, to sign in as admin
If you did not set hostedZoneId, create an A record for the hostname pointing at
pulumi stack output publicIp before the certificate can be issued.
Afterwards
- Sign in, create your own administrator, then remove the bootstrap file: connect with
aws ssm start-session --target $(pulumi stack output instanceId), delete/srv/lumaft/run/admin-password, remove theLUMAFT_LOCAL_ADMIN_PASSWORD_FILEline from/srv/lumaft/compose.yaml, and rundocker compose up --detachin/srv/lumaft. - The instance is a plain Compose and systemd host from here on: backups, upgrades, and everyday commands are on that page.
pulumi destroyrefuses to delete the data volume because it is protected. That is the intent. To remove everything including the database, runpulumi state unprotect 'urn:pulumi:production::lumaft-ec2::aws:ebs/volume:Volume::lumaft-data'first.
Program B: ECS on EC2
Files
# Pulumi.yaml
name: lumaft-ecs
runtime: nodejs
description: Lumaft as an ECS service behind an Application Load Balancer
{
"name": "lumaft-ecs",
"private": true,
"main": "index.ts",
"devDependencies": { "@types/node": "^24", "typescript": "^5.9" },
"dependencies": { "@pulumi/aws": "^7.46", "@pulumi/awsx": "^3.9", "@pulumi/pulumi": "^3.263" }
}
// index.ts
import * as aws from '@pulumi/aws';
import * as awsx from '@pulumi/awsx';
import * as pulumi from '@pulumi/pulumi';
// Lumaft as an ECS service on one EC2 container instance: a VPC with public and private
// subnets, an Application Load Balancer for inbound HTTPS, a NAT Gateway for outbound S3 calls,
// an EBS volume for SQLite, and the init-container pattern for the two secret files.
const config = new pulumi.Config();
const hostname = config.require('hostname'); // e.g. lumaft.example.com
const certificateArn = config.require('certificateArn'); // ACM certificate for the hostname
const imageDigest = config.require('imageDigest'); // sha256:… from the release record
const stateBucket = config.require('stateBucket');
const statePrefix = config.get('statePrefix') ?? '';
const stateRegion = config.get('stateRegion') ?? aws.config.region ?? 'us-east-1';
const layout = config.get('layout') ?? 'project-scoped';
const kmsKeyArn = config.get('kmsKeyArn');
const hostedZoneId = config.get('hostedZoneId');
const allowedCidrs = config.getObject<string[]>('allowedCidrs') ?? ['0.0.0.0/0'];
const instanceType = config.get('instanceType') ?? 't3.medium';
const dataVolumeGib = config.getNumber('dataVolumeGib') ?? 50;
const adminPassword = config.requireSecret('adminPassword');
const region = aws.config.region ?? 'us-east-1';
const image = `ghcr.io/dekglas/lumaft@${imageDigest}`;
const prefixSegment = statePrefix === '' ? '' : `${statePrefix}/`;
// --- Network ------------------------------------------------------------------------------------
// Two public subnets hold the ALB and the NAT Gateway; two private subnets hold the container
// instance. Outbound traffic from the private subnets leaves through the NAT Gateway and the
// Internet Gateway; inbound traffic arrives only through the ALB.
const vpc = new awsx.ec2.Vpc('lumaft', {
cidrBlock: '10.60.0.0/16',
numberOfAvailabilityZones: 2,
natGateways: { strategy: awsx.ec2.NatGatewayStrategy.Single },
subnetSpecs: [
{ type: awsx.ec2.SubnetType.Public, cidrMask: 24 },
{ type: awsx.ec2.SubnetType.Private, cidrMask: 24 },
],
});
const albSecurityGroup = new aws.ec2.SecurityGroup('lumaft-alb', {
vpcId: vpc.vpcId,
description: 'Lumaft ALB: HTTPS from allowed ranges',
ingress: [
{ protocol: 'tcp', fromPort: 443, toPort: 443, cidrBlocks: allowedCidrs },
{ protocol: 'tcp', fromPort: 80, toPort: 80, cidrBlocks: allowedCidrs },
],
egress: [{ protocol: 'tcp', fromPort: 8080, toPort: 8080, cidrBlocks: ['10.60.0.0/16'] }],
});
const taskSecurityGroup = new aws.ec2.SecurityGroup('lumaft-task', {
vpcId: vpc.vpcId,
description: 'Lumaft task: 8080 from the ALB only, HTTPS out',
ingress: [
{ protocol: 'tcp', fromPort: 8080, toPort: 8080, securityGroups: [albSecurityGroup.id] },
],
egress: [{ protocol: 'tcp', fromPort: 443, toPort: 443, cidrBlocks: ['0.0.0.0/0'] }],
});
// --- Secrets ------------------------------------------------------------------------------------
const backendsDocument = JSON.stringify({
schemaVersion: 1,
backends: [
{
kind: 's3',
id: 'production',
displayName: 'Production infrastructure',
bucket: stateBucket,
...(statePrefix === '' ? {} : { prefix: statePrefix }),
layout,
region: stateRegion,
forcePathStyle: false,
enabled: true,
},
],
});
const adminPasswordSecret = new aws.secretsmanager.Secret('lumaft-admin-password', {
recoveryWindowInDays: 7,
});
new aws.secretsmanager.SecretVersion('lumaft-admin-password', {
secretId: adminPasswordSecret.id,
secretString: adminPassword,
});
const backendsSecret = new aws.secretsmanager.Secret('lumaft-backends', {
recoveryWindowInDays: 7,
});
new aws.secretsmanager.SecretVersion('lumaft-backends', {
secretId: backendsSecret.id,
secretString: backendsDocument,
});
// --- IAM ----------------------------------------------------------------------------------------
// Only SSE-KMS buckets need the decrypt grant; see Find your backend details.
const kmsStatements: aws.iam.PolicyStatement[] =
kmsKeyArn === undefined
? []
: [{ Sid: 'DecryptState', Effect: 'Allow', Action: 'kms:Decrypt', Resource: kmsKeyArn }];
const readOnlyState = new aws.iam.Policy('lumaft-read-only-state', {
policy: {
Version: '2012-10-17',
Statement: [
{
Sid: 'ListPulumiState',
Effect: 'Allow',
Action: 's3:ListBucket',
Resource: `arn:aws:s3:::${stateBucket}`,
Condition: { StringLike: { 's3:prefix': [`${prefixSegment}.pulumi/*`] } },
},
{
Sid: 'ReadPulumiState',
Effect: 'Allow',
Action: 's3:GetObject',
Resource: `arn:aws:s3:::${stateBucket}/${prefixSegment}.pulumi/*`,
},
...kmsStatements,
],
},
});
// The task role is what Lumaft runs as.
const taskRole = new aws.iam.Role('lumaft-task', {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: 'ecs-tasks.amazonaws.com' }),
});
new aws.iam.RolePolicyAttachment('lumaft-task-state', {
role: taskRole.name,
policyArn: readOnlyState.arn,
});
// The execution role is what ECS uses to start the task.
const executionRole = new aws.iam.Role('lumaft-execution', {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: 'ecs-tasks.amazonaws.com' }),
});
new aws.iam.RolePolicyAttachment('lumaft-execution-base', {
role: executionRole.name,
policyArn: 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy',
});
new aws.iam.RolePolicy('lumaft-execution-secrets', {
role: executionRole.id,
policy: pulumi.jsonStringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: 'secretsmanager:GetSecretValue',
Resource: [adminPasswordSecret.arn, backendsSecret.arn],
},
],
}),
});
// The container instance's own role: join the cluster, and Session Manager for operators.
const instanceRole = new aws.iam.Role('lumaft-container-instance', {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: 'ec2.amazonaws.com' }),
});
new aws.iam.RolePolicyAttachment('lumaft-container-instance-ecs', {
role: instanceRole.name,
policyArn: 'arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role',
});
new aws.iam.RolePolicyAttachment('lumaft-container-instance-ssm', {
role: instanceRole.name,
policyArn: 'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore',
});
const instanceProfile = new aws.iam.InstanceProfile('lumaft-container-instance', {
role: instanceRole.name,
});
// --- Cluster and the one container instance -------------------------------------------------
const cluster = new aws.ecs.Cluster('lumaft', {});
const privateSubnetId = vpc.privateSubnetIds.apply((ids) => {
const first = ids[0];
if (first === undefined) throw new Error('The VPC has no private subnets.');
return first;
});
const privateSubnet = aws.ec2.getSubnetOutput({ id: privateSubnetId });
const dataVolume = new aws.ebs.Volume(
'lumaft-data',
{
availabilityZone: privateSubnet.availabilityZone,
size: dataVolumeGib,
type: 'gp3',
encrypted: true,
tags: { Name: 'lumaft-data' },
},
{ protect: true },
);
const ecsAmi = aws.ssm.getParameterOutput({
name: '/aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id',
});
const userData = pulumi.interpolate`#!/bin/bash
set -euo pipefail
cat >> /etc/ecs/ecs.config <<'ECS'
ECS_CLUSTER=${cluster.name}
ECS_INSTANCE_ATTRIBUTES={"lumaft.durable-volume":"true"}
ECS
VOLUME_ID="${dataVolume.id.apply((id) => id.replace('-', ''))}"
DEVICE="/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_$VOLUME_ID"
for i in $(seq 1 60); do [ -e "$DEVICE" ] && break; sleep 5; done
blkid "$DEVICE" >/dev/null 2>&1 || mkfs.ext4 -L lumaft-data "$DEVICE"
mkdir -p /srv/lumaft/data
grep -q lumaft-data /etc/fstab || echo 'LABEL=lumaft-data /srv/lumaft/data ext4 defaults,nofail 0 2' >> /etc/fstab
mount -a
chown 1000:1000 /srv/lumaft/data && chmod 700 /srv/lumaft/data
`;
const containerInstance = new aws.ec2.Instance('lumaft-container-instance', {
ami: ecsAmi.value,
instanceType,
subnetId: privateSubnetId,
vpcSecurityGroupIds: [taskSecurityGroup.id],
iamInstanceProfile: instanceProfile.name,
metadataOptions: { httpTokens: 'required' },
userData,
rootBlockDevice: { encrypted: true, volumeType: 'gp3' },
tags: { Name: 'lumaft-container-instance' },
});
new aws.ec2.VolumeAttachment('lumaft-data', {
deviceName: '/dev/sdf',
volumeId: dataVolume.id,
instanceId: containerInstance.id,
stopInstanceBeforeDetaching: true,
});
// --- Task definition ----------------------------------------------------------------------------
const logGroup = new aws.cloudwatch.LogGroup('lumaft', {
name: '/lumaft/application',
retentionInDays: 30,
});
const taskDefinition = new aws.ecs.TaskDefinition('lumaft', {
family: 'lumaft',
networkMode: 'awsvpc',
requiresCompatibilities: ['EC2'],
cpu: '1024',
memory: '2048',
executionRoleArn: executionRole.arn,
taskRoleArn: taskRole.arn,
placementConstraints: [
{ type: 'memberOf', expression: 'attribute:lumaft.durable-volume == true' },
],
volumes: [
{ name: 'lumaft-data', hostPath: '/srv/lumaft/data' },
{ name: 'lumaft-runtime' },
{ name: 'lumaft-tmp' },
],
containerDefinitions: pulumi.jsonStringify([
{
name: 'lumaft-init',
image,
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/admin-password /run/lumaft/backends.json',
'chmod 0600 /run/lumaft/admin-password /run/lumaft/backends.json',
].join(' && '),
],
secrets: [
{ name: 'BOOTSTRAP_PASSWORD', valueFrom: adminPasswordSecret.arn },
{ name: 'BACKENDS_JSON', valueFrom: backendsSecret.arn },
],
mountPoints: [
{ sourceVolume: 'lumaft-runtime', containerPath: '/run/lumaft' },
{ sourceVolume: 'lumaft-tmp', containerPath: '/tmp' },
],
},
{
name: 'lumaft',
image,
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://${hostname}` },
],
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': logGroup.name,
'awslogs-region': region,
'awslogs-stream-prefix': 'lumaft',
},
},
},
]),
});
// --- Load balancer ------------------------------------------------------------------------------
const alb = new aws.lb.LoadBalancer('lumaft', {
loadBalancerType: 'application',
subnets: vpc.publicSubnetIds,
securityGroups: [albSecurityGroup.id],
});
const targetGroup = new aws.lb.TargetGroup('lumaft', {
vpcId: vpc.vpcId,
targetType: 'ip',
protocol: 'HTTP',
port: 8080,
healthCheck: {
path: '/api/v1/readiness',
matcher: '200',
interval: 30,
timeout: 5,
healthyThreshold: 2,
unhealthyThreshold: 3,
},
});
const httpsListener = new aws.lb.Listener('lumaft-https', {
loadBalancerArn: alb.arn,
port: 443,
protocol: 'HTTPS',
certificateArn,
sslPolicy: 'ELBSecurityPolicy-TLS13-1-2-2021-06',
defaultActions: [{ type: 'forward', targetGroupArn: targetGroup.arn }],
});
new aws.lb.Listener('lumaft-http-redirect', {
loadBalancerArn: alb.arn,
port: 80,
protocol: 'HTTP',
defaultActions: [
{ type: 'redirect', redirect: { port: '443', protocol: 'HTTPS', statusCode: 'HTTP_301' } },
],
});
// --- Service ------------------------------------------------------------------------------------
const service = new aws.ecs.Service(
'lumaft',
{
cluster: cluster.arn,
taskDefinition: taskDefinition.arn,
launchType: 'EC2',
desiredCount: 1,
// Stop the old task before starting the new one: two tasks cannot share the SQLite volume.
deploymentMinimumHealthyPercent: 0,
deploymentMaximumPercent: 100,
deploymentCircuitBreaker: { enable: true, rollback: true },
healthCheckGracePeriodSeconds: 60,
networkConfiguration: {
subnets: vpc.privateSubnetIds,
securityGroups: [taskSecurityGroup.id],
assignPublicIp: false,
},
loadBalancers: [
{ targetGroupArn: targetGroup.arn, containerName: 'lumaft', containerPort: 8080 },
],
},
{ dependsOn: [httpsListener, containerInstance] },
);
if (hostedZoneId !== undefined) {
new aws.route53.Record('lumaft', {
zoneId: hostedZoneId,
name: hostname,
type: 'A',
aliases: [{ name: alb.dnsName, zoneId: alb.zoneId, evaluateTargetHealth: true }],
});
}
export const loadBalancerDnsName = alb.dnsName;
export const url = `https://${hostname}`;
export const clusterName = cluster.name;
export const serviceName = service.name;
export const adminPasswordSecretArn = adminPasswordSecret.arn;
Configure and deploy
npm install
pulumi stack init production
pulumi config set aws:region us-east-1
pulumi config set hostname lumaft.example.com
pulumi config set certificateArn arn:aws:acm:us-east-1:123456789012:certificate/REPLACE
pulumi config set imageDigest sha256:REPLACE_WITH_THE_RELEASE_DIGEST
pulumi config set stateBucket acme-pulumi-state
pulumi config set statePrefix team/platform
pulumi config set layout project-scoped
pulumi config set hostedZoneId Z0123456789ABCDEFGHIJ
pulumi config set --secret adminPassword "$(openssl rand -base64 24)"
pulumi up
The same optional settings apply. The VPC, NAT Gateway, and load balancer take several minutes;
the container instance then joins the cluster and the service places the task once the
lumaft.durable-volume attribute is registered. Watch it settle:
aws ecs describe-services --cluster "$(pulumi stack output clusterName)" \
--services "$(pulumi stack output serviceName)" \
--query 'services[0].{running:runningCount,events:events[0].message}'
curl -fsS "$(pulumi stack output url)/api/v1/readiness"
How traffic flows
Exactly as the ECS guide describes: browsers reach
the ALB in the public subnets through the Internet Gateway; the ALB forwards to the task on
8080 in a private subnet; the task's S3 reads, image pulls, and licensing calls leave through
the NAT Gateway and then the Internet Gateway. The task security group admits 8080 from the
ALB's security group only.
Afterwards
- After first sign-in, remove the
BOOTSTRAP_PASSWORDsecret and theLUMAFT_LOCAL_ADMIN_PASSWORD_FILEvariable from the task definition inindex.ts, thenpulumi up. The service replaces the task; the database on the volume is untouched. - Upgrades are
pulumi config set imageDigest …andpulumi up. Take a cold backup first — set the service's desired count to zero, snapshot or copy the volume, then run the update. See Upgrades and versioning. - To keep S3 traffic off the NAT Gateway, add an S3 gateway endpoint to the private route
tables (
aws.ec2.VpcEndpointwithvpcEndpointType: 'Gateway'). Lumaft needs no change. - The data volume is protected from
pulumi destroy; unprotect it deliberately, as above, if you want it gone.
Adapting the programs
- An existing VPC. Replace the
awsx.ec2.Vpcin Program B withaws.ec2.getVpcOutputandgetSubnetsOutputlookups for your public and private subnets. Everything else stays. - Fargate. Fargate cannot mount a retained EBS volume, so it needs PostgreSQL (Business).
Remove the container instance, volume, and placement constraint; add a Secrets Manager entry
for the PostgreSQL URL that the init container writes to
/run/lumaft/postgres-url; setLUMAFT_POSTGRES_URL_FILEon thelumaftcontainer; and setrequiresCompatibilitiesto['FARGATE']with a Fargate capacity provider on the cluster. - Several backends. Extend
backendsDocumentwith one record per bucket, prefix, or layout, and add a matching statement pair toreadOnlyState. - Private CA on an S3-compatible store. Mount the CA bundle into the container and set
NODE_EXTRA_CA_CERTS; see Set up on VMware.