Deploying Dockerized Node.js Applications to AWS
By Isaiah Daniel·
Multi-stage Dockerfiles, ECR, ECS Fargate vs App Runner vs EC2, health checks, SSM secrets, and a GitHub Actions path that does not bake credentials into the image.
The first time I containerized a NestJS API for production, the image worked on my laptop and failed in the cloud for three different reasons: the process ran as root, secrets were baked into the image as ENV lines, and the health check hit / which returned 404 because the app only mounts under /api. The deploy "succeeded" (the task was running) while the load balancer kept draining every target. Containers do not remove ops. They just move the failure modes into the image, the registry, and the task definition.
This post is the path I use for a single Node.js service on AWS: multi-stage Dockerfile, push to ECR, pick a compute target, wire health checks and secrets, and ship with GitHub Actions. I will use Kora Assist, a fictional AI assistant for Nigerian small businesses, as the demo app. If you still prefer a bare process on a VM, the systemd setup in Running NestJS on EC2 with systemd is the sibling of this post.
What you are actually shipping
A deployable Node container is not "install node and copy the repo." You want:
- A small runtime image with only production dependencies.
- A non-root user and a PID 1 that forwards signals.
- Config and secrets injected at runtime, never at build time.
- A health endpoint the orchestrator can probe without auth.
- A registry and a pipeline that can push and roll back by digest.
Everything else (Fargate vs App Runner vs EC2) is where that image runs.
Multi-stage Dockerfile
Kora's API is a NestJS service. The build stage compiles TypeScript; the runtime stage copies only dist and production node_modules.
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY tsconfig*.json nest-cli.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends dumb-init \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nestjs
COPY --from=build --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=build --chown=nestjs:nodejs /app/dist ./dist
COPY --from=build --chown=nestjs:nodejs /app/package.json ./
USER nestjs
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]
Three gotchas I keep hitting:
- Shell form
CMDswallows SIGTERM. If you writeCMD node dist/main.jswithoutexecor an init process, Node is not PID 1 and never sees the stop signal. ECS or Kubernetes then waits for the stop timeout and kills the task hard.dumb-init(ortini) plus JSONCMDfixes that. - Do not copy
.envinto the image. Build args for secrets are also a trap: they show up indocker history. Pass runtime config with the task definition or App Runner environment, sourced from SSM or Secrets Manager. npm ciin the runtime stage bloats the image if you skip the prune. Build once, prune, copy the pruned tree.
Local check before you push:
docker build -t kora-api:local .
docker run --rm -p 3000:3000 \
-e DATABASE_URL=postgres://... \
-e PORT=3000 \
kora-api:local
curl -sf http://127.0.0.1:3000/health
Health checks that mean something
The orchestrator needs a cheap, authenticated-free probe. Nest example:
// health.controller.ts
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok' };
}
}
Wire graceful shutdown so in-flight requests finish when the task stops (same idea as the systemd post):
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);
}
For ECS, put the probe on the container health check and on the target group. A container that answers /health while the ALB still points at / will show as healthy in ECS and unhealthy in the load balancer. Align the paths.
Docker HEALTHCHECK is optional for Fargate (ECS prefers the task definition health check), but useful for local and App Runner-style platforms:
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ECR: push by digest, not by floating tags alone
Create a private repo, tag with both git SHA and latest if you need a moving pointer, but deploy by digest in production task definitions so a retag cannot silently change what is running.
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=eu-west-1
REPO=kora-api
aws ecr get-login-password --region "$REGION" \
| docker login --username AWS --password-stdin "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com"
DIGEST_TAG="$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO:$GITHUB_SHA"
docker build -t "$DIGEST_TAG" .
docker push "$DIGEST_TAG"
# Resolve digest after push for the task definition
IMAGE_URI=$(aws ecr describe-images \
--repository-name "$REPO" \
--image-ids imageTag="$GITHUB_SHA" \
--query 'imageDetails[0].imageDigest' \
--output text)
echo "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO@$IMAGE_URI"
Turn on image scanning on push. It will not catch bad Nest code, but it will catch a base image with a known critical CVE before you promote to prod.
Where to run it: Fargate, EC2, or App Runner
I pick based on how much control I need, not on what is fashionable.
| Target | When I use it | Trade-off |
|---|---|---|
| ECS Fargate | Default for APIs with an ALB, autoscaling, multiple services | Pay for vCPU/memory while running; cold starts are rare but task definition YAML is real work |
| ECS on EC2 | GPU, host volumes, or you already run a fleet and want denser packing | You own patching, capacity, and drain behavior |
| App Runner | Single public HTTP service, small team, want git/ECR to URL fast | Less control over networking, sidecars, and custom load balancer rules |
| EC2 + Docker Compose / systemd | One box, one or two containers, budget or simplicity first | No fancy autoscaling; see the systemd NestJS post |
For Kora Assist's public API, Fargate behind an Application Load Balancer is the default. App Runner is fine for an internal admin tool. I do not put payment-adjacent write paths on App Runner until I am happy with VPC connectors, egress rules, and how deploys roll back.
Minimal ECS task definition shape (secrets from SSM, not plaintext in git):
{
"family": "kora-api",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::ACCOUNT:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::ACCOUNT:role/koraApiTaskRole",
"containerDefinitions": [
{
"name": "api",
"image": "ACCOUNT.dkr.ecr.eu-west-1.amazonaws.com/kora-api@sha256:REPLACE",
"portMappings": [{ "containerPort": 3000, "protocol": "tcp" }],
"essential": true,
"environment": [
{ "name": "NODE_ENV", "value": "production" },
{ "name": "PORT", "value": "3000" }
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:ssm:eu-west-1:ACCOUNT:parameter/kora/prod/DATABASE_URL"
}
],
"healthCheck": {
"command": ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 40
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/kora-api",
"awslogs-region": "eu-west-1",
"awslogs-stream-prefix": "api"
}
}
}
]
}
The execution role pulls from ECR and reads SSM for injection. The task role is what the app uses at runtime (S3, SQS, and so on). Mixing those two is how teams accidentally grant the app permission to rewrite its own task definition.
Secrets from SSM (not from GitHub Actions env dumps)
Store parameters as SecureString. Reference them in the task definition secrets array as above. In the app, read process.env.DATABASE_URL like any other env var; ECS injects them before CMD starts.
GitHub Actions needs an IAM role (OIDC) that can push to ECR and call ecs:UpdateService. It should not need the database password. If your workflow prints env: with production secrets for "migrations," you have a leak waiting for a failed job log.
GitHub Actions sketch
name: Deploy API
on:
push:
branches: [main]
paths: ['apps/api/**', '.github/workflows/deploy-api.yml']
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT:role/github-deploy-kora
aws-region: eu-west-1
- uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
env:
ECR: ${{ env.ACCOUNT }}.dkr.ecr.eu-west-1.amazonaws.com/kora-api
run: |
docker build -t "$ECR:$GITHUB_SHA" apps/api
docker push "$ECR:$GITHUB_SHA"
- name: Force new deployment
run: |
aws ecs update-service \
--cluster kora-prod \
--service kora-api \
--force-new-deployment
Prefer rendering a new task definition revision with the image digest pinned, then updating the service to that revision. --force-new-deployment alone reuses whatever image URI is already in the active definition, which is wrong if you only pushed a new tag and never changed the definition.
Circuit breakers on the ECS service (deploymentCircuitBreaker.enable + rollback) save you when the new task fails health checks. Without them, a bad image can sit in a crash loop while you discover it from pager duty instead of from the deploy job.
Failure modes worth rehearsing
- Image works locally, fails on Fargate: usually missing CA certs, wrong
WORKDIR, or binding to127.0.0.1instead of0.0.0.0. Nest'slisten(port)binds all interfaces by default; custom adapters sometimes do not. - Healthy container, 502 from ALB: security group, wrong target port, or health path mismatch.
- Task starts then stops: check CloudWatch logs first. Often it is a missing SSM parameter or the task role cannot
kms:Decryptthe SecureString. - Deploy succeeds, traffic still on old tasks: desired count and deployment configuration; confirm the new task definition revision is what the service references.
Takeaways
- Multi-stage build, non-root user, and a real init process are the baseline, not polish.
- Prefer ECR digests in production task definitions; tags alone are mutable.
- Pick Fargate for most APIs, App Runner for simple public HTTP, EC2 when you need the host.
- Inject secrets from SSM or Secrets Manager at runtime; never bake them into the image or the workflow log.
- Align container health checks with the load balancer path, and turn on ECS circuit breaker rollback before you need it.