Skip to content
Lumaft documentation contents
Lumaft documentation

Deploy with Pulumi

Two complete Pulumi programs — one EC2 instance, or an ECS service behind an ALB — that stand Lumaft up with a single pulumi up.

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 the LUMAFT_LOCAL_ADMIN_PASSWORD_FILE line from /srv/lumaft/compose.yaml, and run docker compose up --detach in /srv/lumaft.
  • The instance is a plain Compose and systemd host from here on: backups, upgrades, and everyday commands are on that page.
  • pulumi destroy refuses to delete the data volume because it is protected. That is the intent. To remove everything including the database, run pulumi 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_PASSWORD secret and the LUMAFT_LOCAL_ADMIN_PASSWORD_FILE variable from the task definition in index.ts, then pulumi up. The service replaces the task; the database on the volume is untouched.
  • Upgrades are pulumi config set imageDigest … and pulumi 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.VpcEndpoint with vpcEndpointType: '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.Vpc in Program B with aws.ec2.getVpcOutput and getSubnetsOutput lookups 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; set LUMAFT_POSTGRES_URL_FILE on the lumaft container; and set requiresCompatibilities to ['FARGATE'] with a Fargate capacity provider on the cluster.
  • Several backends. Extend backendsDocument with one record per bucket, prefix, or layout, and add a matching statement pair to readOnlyState.
  • 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.