{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreiarei7oygvdutfe4ixwg5bmxt5k2qsfavcjcpsuygvfprsq4pddxe",
    "uri": "at://did:plc:6u4awktizhivwgqxl5j67h4k/app.bsky.feed.post/3mfg3ztk6iam2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreia5gpp2k72ruxgjyxw72tdwxy6735qwnnwc6filc7knxlzb4j5to4"
    },
    "mimeType": "image/jpeg",
    "size": 79452
  },
  "description": "I recently revisited cdk-websocket-server, a demo I published in 2023 for running a WebSocket server on ECS Fargate behind CloudFront. The original worked, but it carried unnecessary complexity — EC2 instances backing a Fargate cluster, a custom Lambda to patch CloudFront headers, and a single-stage Docker build running as root. Here's what changed and why.\n\n\n\nDropping the EC2 Auto Scaling Group\n\n\nThe original created an EC2 Auto Scaling Group alongside the Fargate cluster:\n\n\n// Old approach\ncon",
  "path": "/modernizing-websocket-server/",
  "publishedAt": "2026-02-22T02:34:45.000Z",
  "site": "https://www.subaud.io",
  "tags": [
    "cdk-websocket-server",
    "Container Insights",
    "cdk-nag",
    "HTTP desync attacks",
    "Building and Deploying a WebSocket Server with Fargate and CDK",
    "https://github.com/schuettc/cdk-websocket-server"
  ],
  "textContent": "I recently revisited cdk-websocket-server, a demo I published in 2023 for running a WebSocket server on ECS Fargate behind CloudFront. The original worked, but it carried unnecessary complexity — EC2 instances backing a Fargate cluster, a custom Lambda to patch CloudFront headers, and a single-stage Docker build running as root. Here's what changed and why.\n\n## Dropping the EC2 Auto Scaling Group\n\nThe original created an EC2 Auto Scaling Group alongside the Fargate cluster:\n\n\n    // Old approach\n    const autoScalingGroup = new AutoScalingGroup(this, 'AutoScalingGroup', {\n      vpc: props.vpc,\n      instanceType: new InstanceType('m6i.large'),\n      machineImage: EcsOptimizedImage.amazonLinux2(),\n      desiredCapacity: 1,\n    });\n\n    const capacityProvider = new AsgCapacityProvider(this, 'capacityProvider', {\n      autoScalingGroup: autoScalingGroup,\n    });\n\n    this.cluster.addAsgCapacityProvider(capacityProvider);\n\n\nThis was wrong. Fargate _is_ serverless containers — the entire point is that you don't manage the underlying compute. Having an EC2 ASG capacity provider on a Fargate cluster doesn't add anything; it's a leftover from when the project might have used EC2-backed ECS.\n\nThe new version just creates the cluster:\n\n\n    this.cluster = new Cluster(this, 'Cluster', {\n      vpc: props.vpc,\n      clusterName: 'websocket-service',\n      containerInsightsV2: ContainerInsights.ENHANCED,\n    });\n\n\nNo EC2 instances, no ASG, no capacity provider. Fargate handles compute provisioning. We also enabled Container Insights in enhanced mode — better metrics with zero additional infrastructure.\n\n## Task-Level Auto Scaling\n\nThe original had no auto scaling at the task level. If traffic increased, you were stuck with whatever `desiredCount` you set at deploy time.\n\nThe new version scales Fargate tasks based on active connections:\n\n\n    const scalableTarget = websocketService.autoScaleTaskCount({\n      minCapacity: 1,\n      maxCapacity: 5,\n    });\n\n    scalableTarget.scaleOnRequestCount('RequestScaling', {\n      requestsPerTarget: 5,\n      targetGroup: webSocketTargetGroup,\n    });\n\n\nALBs track active WebSocket connections as requests. When connections per target exceed the threshold, ECS automatically provisions new Fargate tasks. This is the right level to scale at — task instances, not EC2 instances.\n\n## Native Custom Headers (No More Custom Resource)\n\nThe biggest cleanup was the CloudFront-to-ALB security mechanism. The original used a Custom Resource Lambda to patch the CloudFront distribution config after creation, because CDK didn't support custom origin headers natively:\n\n\n    // Old approach - Custom Resource to patch headers\n    new CustomResource(this, 'customHeaderCustomResource', {\n      serviceToken: customHeaderCustomResourceProvider.serviceToken,\n      properties: {\n        DistributionId: this.distribution.distributionId,\n        Origins: [\n          {\n            OriginId: 'defaultOrigin',\n            CustomHeaders: [\n              {\n                HeaderName: props.customHeader,\n                HeaderValue: props.randomString,\n              },\n            ],\n          },\n        ],\n      },\n    });\n\n\nThis involved a Lambda function that called `GetDistributionConfig`, merged in the custom headers, and called `UpdateDistribution`. It worked, but it was fragile — you had to handle the `ETag` for optimistic concurrency, match origin IDs correctly, and deal with the Lambda runtime and IAM permissions.\n\nCDK now supports `customHeaders` directly on `LoadBalancerV2Origin`:\n\n\n    const defaultOrigin = new LoadBalancerV2Origin(props.applicationLoadBalancer, {\n      httpPort: 80,\n      protocolPolicy: OriginProtocolPolicy.HTTP_ONLY,\n      originId: 'defaultOrigin',\n      customHeaders: {\n        [props.customHeader]: props.randomString,\n      },\n    });\n\n\nThree lines replace an entire Custom Resource stack. The headers are set during synthesis, deployed as part of the CloudFormation template, and managed through the normal update lifecycle. No Lambda, no runtime API calls, no race conditions.\n\n## Multi-Stage Docker Build\n\nThe original Dockerfile was a single stage running as root:\n\n\n    # Old approach\n    FROM --platform=linux/arm64 node:20\n    WORKDIR /usr/src/app\n    COPY . .\n    RUN yarn && yarn build\n    EXPOSE 8080\n    CMD [ \"node\", \"dist/server.js\" ]\n\n\nThe new version uses a multi-stage build:\n\n\n    FROM --platform=linux/arm64 node:20-alpine AS build\n    WORKDIR /usr/src/app\n    COPY package*.json ./\n    RUN npm ci\n    COPY . .\n    RUN npm run build\n\n    FROM --platform=linux/arm64 node:20-alpine\n    ENV NODE_ENV=production\n    RUN apk add --no-cache curl\n    USER node\n    WORKDIR /usr/src/app\n    COPY --chown=node:node package*.json ./\n    COPY --from=build --chown=node:node /usr/src/app/node_modules ./node_modules\n    COPY --from=build --chown=node:node /usr/src/app/dist ./dist\n    EXPOSE 8080\n    CMD [ \"node\", \"dist/server.js\" ]\n\n\nWhat this buys you:\n\n  * **Smaller image** — Alpine base, no dev dependencies or source files in the final image\n  * **Non-root execution** — Runs as the built-in `node` user. If the container is compromised, the attacker doesn't have root\n  * **Deterministic installs** — `npm ci` instead of `yarn`, locked to `package-lock.json`\n  * **Layer caching** — `package*.json` is copied first, so dependency installation is cached unless dependencies change\n\n\n\n## Simplified Security Groups\n\nThe original manually created security groups and wired them together:\n\n\n    // Old approach\n    const albSecurityGroup = new SecurityGroup(this, 'ALBSecurityGroup', {\n      vpc: props.vpc,\n      description: 'Security Group for ALB',\n      allowAllOutbound: true,\n    });\n\n    webSocketServiceSecurityGroup.connections.allowFrom(\n      new Connections({\n        securityGroups: [albSecurityGroup],\n      }),\n      Port.tcp(8080),\n      'allow traffic on port 8080 from the ALB security group',\n    );\n\n\nThe new version uses CDK's higher-level `connections` API:\n\n\n    websocketService.connections.allowFrom(\n      props.applicationLoadBalancer,\n      Port.tcp(8080),\n      'Allow traffic from ALB on port 8080',\n    );\n\n\nCDK's `FargateService` and `ApplicationLoadBalancer` both implement `IConnectable`. When you call `allowFrom` between them, CDK creates the security group rules automatically. No need to create explicit security group resources or wrap them in `Connections` objects.\n\n## Graceful Shutdown\n\nThe original server had no shutdown handling. When ECS stopped a task — whether scaling down, deploying, or replacing an unhealthy container — every active WebSocket connection dropped immediately with no warning.\n\nThe new server handles `SIGTERM` and `SIGINT`:\n\n\n    const shutdown = () => {\n      websocketServer.clients.forEach((client: WebSocket) => {\n        if (client.readyState === WebSocket.OPEN) {\n          client.close(1001, 'Server shutting down');\n        }\n      });\n\n      server.close(() => {\n        process.exit(0);\n      });\n\n      setTimeout(() => {\n        process.exit(1);\n      }, 10000);\n    };\n\n    process.on('SIGTERM', shutdown);\n    process.on('SIGINT', shutdown);\n\n\nEach connected client receives a `1001` (Going Away) close code, giving client-side code the opportunity to reconnect to a different task. The HTTP server drains, and a 10-second timeout ensures the process exits even if something hangs.\n\nFor REST APIs, this is nice to have. For WebSocket servers, it's mandatory.\n\n## Security Testing with cdk-nag\n\nThe project now includes cdk-nag as part of the test suite:\n\n\n    test('No unsuppressed Errors', () => {\n      const app = new App();\n      const stack = new WebSocketServer(app, 'test', {});\n      Aspects.of(stack).add(new AwsSolutionsChecks());\n\n      const errors = Annotations.fromStack(stack).findError(\n        '*',\n        Match.stringLikeRegexp('AwsSolutions-.*'),\n      );\n      expect(errors).toHaveLength(0);\n    });\n\n\nThis runs the AWS Solutions rule pack at synth time. Security findings either get fixed or explicitly suppressed with documented reasons. It catches things like unencrypted buckets, overly permissive IAM policies, and missing TLS enforcement — before you deploy.\n\n## ALB Hardening\n\nA small addition: `dropInvalidHeaderFields: true` on the ALB:\n\n\n    this.applicationLoadBalancer = new ApplicationLoadBalancer(\n      this,\n      'ApplicationLoadBalancer',\n      {\n        vpc: this.vpc,\n        internetFacing: true,\n        dropInvalidHeaderFields: true,\n      },\n    );\n\n\nThis prevents HTTP desync attacks by dropping requests with malformed headers. It's one line and costs nothing.\n\n## VPC Simplification\n\nThe VPC was also cleaned up — no NAT gateways (Fargate tasks have public IPs), public subnets only:\n\n\n    this.vpc = new Vpc(this, 'VPC', {\n      natGateways: 0,\n      subnetConfiguration: [\n        {\n          cidrMask: 24,\n          name: 'ServerPublic',\n          subnetType: SubnetType.PUBLIC,\n          mapPublicIpOnLaunch: true,\n        },\n      ],\n      maxAzs: 2,\n    });\n\n\nFor a demo that doesn't need private networking, this keeps costs at zero for the VPC itself.\n\n## Summary\n\nMost of these changes are about removing things that shouldn't have been there — EC2 instances backing a Fargate cluster, a custom resource working around a CDK limitation that no longer exists, a single-stage Dockerfile running as root. The additions (auto scaling, graceful shutdown, cdk-nag, load testing) are the things you'd want in any production WebSocket deployment.\n\nThe updated post and repo are at:\n\n  * Building and Deploying a WebSocket Server with Fargate and CDK\n  * https://github.com/schuettc/cdk-websocket-server\n\n",
  "title": "Modernizing a CDK Project - WebSocket Server with Fargate",
  "updatedAt": "2026-02-22T02:34:45.000Z"
}