ECS Fargate Compute Layer

The ECS module (createEcs) is the most complex module in the CDK codebase. It defines the compute layer: an ECS Fargate cluster running a Go application container with a RaftDB sidecar, fronted by an Application Load Balancer (ALB) with HTTPS termination. It is always the last module called in the AwsplaceStack constructor because it references virtually every other resource.

Module overview

AspectDetail
Filelib/ecs.ts
FunctioncreateEcs(scope: Construct, input: EcsInput): EcsOutput
ResourcesFargate cluster, task definition (2 containers), Fargate service, ALB, HTTPS listener, target group, Route 53 ws. record
Return type{ cluster, service, taskDefinition, alb }

ECS Fargate Architecture Diagram


1. Task Definition — Two Containers, One Task

The Fargate task runs two containers in a single task. They share the same ENI, the same EFS volume, and communicate over localhost.

ContainerImage SourcePortUserCPU/MemoryPurpose
RaftDB (sidecar)ECR (SHA256 digest)910010001:10001 (non-root)Shared 1024/2048Raft consensus engine + WAL + canvas storage
App (Go server)ECR (tagged image)8980DefaultShared 1024/2048HTTP/WebSocket application server

Task resources: 1024 CPU units (1 vCPU) / 2048 MiB memory — shared between both containers.

Container dependency:

appContainer.addContainerDependencies({
  container: raftDbContainer,
  condition: ecs.ContainerDependencyCondition.HEALTHY,
});

The App container will not start until the RaftDB sidecar passes its health check. This prevents race conditions where the Go server tries to connect to RaftDB before it is ready.


2. RaftDB Sidecar Container

const raftDbContainer = taskDefinition.addContainer('RaftDb', {
  image: ecs.ContainerImage.fromEcrRepository(ecrRepo, raftDbImageDigest),
  user: '10001:10001',
  workingDirectory: '/data/raftdb',
  environment: {
    RAFTDB_PORT: '9100',
    RAFTDB_DATA_DIR: '/data/raftdb',
    RAFTDB_READY_FILE: '/tmp/raftdb-ready',
    RAFTDB_RESTORE_FROM_S3: 'false',
    RAFTDB_SNAPSHOT_BUCKET: raftDb.snapshotBucket.bucketName,
    RAFTDB_SNAPSHOT_PREFIX: 'production/member-1',
    RAFTDB_SNAPSHOT_INTERVAL_SECONDS: '300',
  },
  healthCheck: {
    command: ['CMD', '/usr/local/bin/raftdb-healthcheck'],
    interval: Duration.seconds(5),
    timeout: Duration.seconds(3),
    retries: 10,
    startPeriod: Duration.seconds(15),
  },
  stopTimeout: Duration.seconds(120),
  logging: ecs.LogDriver.awsLogs({ streamPrefix: 'raftdb' }),
});

Key configuration decisions

SettingValueWhy
Image referenceSHA256 digest (not tag)Immutable — guarantees the exact tested image is deployed
User10001:10001Non-root container — follows security best practices
Working directory/data/raftdbEFS mount point — data persists across task restarts
Ready file/tmp/raftdb-readyTouch-based signal that RaftDB is accepting connections
Restore from S3false (production)Production starts from existing WAL, not S3 snapshot
Snapshot interval300 seconds (5 min)Periodic snapshots to S3 for disaster recovery
Health checkCustom script, 5s intervalValidates RaftDB is responding to internal health queries
Stop timeout120 secondsGraceful shutdown — allows WAL flush and snapshot before termination
Start period15 secondsGives RaftDB time to replay WAL and open its port

3. App Container (Go Server)

const appContainer = taskDefinition.addContainer('App', {
  image: ecs.ContainerImage.fromEcrRepository(ecrRepo, imageTag),
  portMappings: [{ containerPort: 8980 }],
  environment: {
    PORT: '8980',
    DATA_MODE: 'raftdb-only',
    BACKEND: 'raftdb',
    STORAGE: 'raftdb',
    RAFTDB_ADDR: '127.0.0.1:9100',
    AUTH_ENABLED: 'false',
    ALLOWED_ORIGINS: `https://${domainName}`,
    DDB_CONFIG: db.configTable.tableName,
    DDB_BANS: db.bansTable.tableName,
    DDB_MILESTONES: db.milestonesTable.tableName,
    DDB_HISTORY: db.historyTable.tableName,
    S3_CANVAS_BUCKET: storage.canvasBucket.bucketName,
    S3_EXPORTS_BUCKET: storage.exportsBucket.bucketName,
  },
  secrets: {
    SESSION_SECRET: ecs.Secret.fromSecretsManager(appSecret, 'SESSION_SECRET'),
  },
  logging: ecs.LogDriver.awsLogs({ streamPrefix: 'awsplace' }),
});

Key configuration decisions

SettingValueWhy
Image referenceTag (commit SHA)Application images use mutable tags for CI/CD roll-forward
DATA_MODEraftdb-onlyProduction mode — all data flows through RaftDB
RAFTDB_ADDR127.0.0.1:9100Localhost — sidecar communication, no network hop
AUTH_ENABLEDfalseAuth is handled by the Lambda/API Gateway layer, not ECS
ALLOWED_ORIGINShttps://${domainName}CORS — matches the Amplify frontend domain
DynamoDB table namesPassed as env varsThe Go server uses the AWS SDK with these table names
SESSION_SECRETFrom Secrets ManagerNever in plaintext env vars — injected at container startup
CloudWatch logsawslogs driver, awsplace prefixAll app logs go to CloudWatch

4. Fargate Service Configuration

const service = new ecs.FargateService(scope, 'Service', {
  cluster,
  taskDefinition,
  desiredCount: 1,
  minHealthyPercent: 0,
  maxHealthyPercent: 100,
  assignPublicIp: true,
  vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
  securityGroups: [ecsSg],
});

// Deployment circuit breaker
const cfnService = service.node.defaultChild as ecs.CfnService;
cfnService.addPropertyOverride('DeploymentConfiguration.DeploymentCircuitBreaker', {
  Enable: true,
  Rollback: true,
});

// Disable AZ rebalancing
cfnService.addPropertyOverride('AvailabilityZoneRebalancing', 'DISABLED');

Deployment safety decisions

SettingValueWhy
desiredCount1Single-instance — RaftDB WAL can only have one writer
minHealthyPercent0Stop before replacing — prevents two tasks from accessing EFS simultaneously
maxHealthyPercent100Only one task runs at any time
assignPublicIptrueTasks need direct internet access to reach DynamoDB, S3, ECR APIs
Subnet typePUBLICMatches assignPublicIp: true
Circuit breakerEnable: true, Rollback: trueAuto-rollback on deployment failure
AZ RebalancingDISABLEDPrevents ECS from moving the task between AZs (would disrupt EFS mount)

Why single-instance ECS?

ReasonExplanation
RaftDB WAL ownershipOnly one writer can own the EFS WAL at a time — two concurrent tasks would corrupt it
EFS single-attachEFS access points are designed for single-writer workloads
Cost optimizationOne Fargate task is sufficient for the current user base
SimplicityNo session affinity, no cross-task state sync, no distributed consensus needed
Cooldowns in-memoryLost on restart, but not critical — re-created on next user action

IMPORTANT: ECS task-count autoscaling is explicitly prohibited for RaftDB voters. This is a single-member RaftDB deployment by design.


5. Application Load Balancer

const alb = new elbv2.ApplicationLoadBalancer(scope, 'ALB', {
  vpc,
  internetFacing: true,
  securityGroup: albSg,
  vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
});

const httpsListener = alb.addListener('HttpsListener', {
  port: 443,
  open: true,
  certificates: [certificate],
});

const targetGroup = httpsListener.addTargets('ECS', {
  port: 8980,
  protocol: elbv2.ApplicationProtocol.HTTP,
  targets: [service.loadBalancerTarget({
    containerName: 'App',
    containerPort: 8980,
  })],
  healthCheck: {
    path: '/health',
    interval: Duration.seconds(30),
    timeout: Duration.seconds(5),
    healthyThresholdCount: 2,
  },
});

ALB settings

SettingValueReason
SchemeinternetFacing: truePublic application — users connect from the internet
Listener port443 (HTTPS only)TLS termination at the ALB
CertificateACM wildcard *.domainNameCovers ws.domainName
Target port8980The Go app’s HTTP port
Health check path/healthReturns ok — lightweight, no DB queries
Health check interval30 secondsBalanced between responsiveness and cost
Health check timeout5 secondsThe Go server responds in <1ms
Healthy threshold2 consecutive successesAvoids flapping on transient errors
Deregistration delay30 secondsAllows in-flight requests to complete during deployment
Idle timeout3600 seconds (1 hour)WebSocket connections would be dropped by the default 60s timeout

HTTP → HTTPS redirect

alb.addListener('HttpListener', {
  port: 80,
  open: true,
  defaultAction: elbv2.ListenerAction.redirect({
    protocol: 'HTTPS',
    port: '443',
    permanent: true,
  }),
});

All HTTP traffic receives a 301 Permanent Redirect to HTTPS. No plaintext traffic reaches the application.


6. WebSocket DNS Record

new route53.ARecord(scope, 'WsRecord', {
  zone: hostedZone,
  recordName: `ws.${domainName}`,
  target: route53.RecordTarget.fromAlias(
    new route53_targets.LoadBalancerTarget(alb)
  ),
});

This creates ws.<domain> pointing to the ALB. The split-domain architecture routes WebSocket traffic to a dedicated subdomain:

SubdomainTargetProtocol
domain.comAmplifyHTTPS (Amplify-managed TLS)
api.domain.comAPI Gateway → LambdaHTTPS (ACM wildcard cert)
ws.domain.comALB → ECSHTTPS/WSS (ACM wildcard cert)

The WebSocket idle timeout (3600s) is critical here — without it, idle viewers would be disconnected every 60 seconds.