A hands-on AWS deployment lab for The DevSec Blueprint. DSB Lite is a small Node.js/Express service that serves a single-page website, packaged as a Docker image and deployed to AWS ECS Fargate behind an internet-facing Application Load Balancer, all provisioned with Terraform and shipped by a GitHub Actions pipeline that authenticates to AWS through OIDC.
The lab intentionally reuses your account's default VPC and default subnets and assigns tasks a public IP, so there is no NAT Gateway, no custom domain, and no extra networking to manage. The site is reached over HTTP at the AWS-generated load balancer DNS name.
GitHub Actions (OIDC)
│ build → push (tag = commit SHA)
▼
Amazon ECR ──(immutable, scan-on-push)
│ pull
▼
Internet ─▶ ALB (HTTP :80) ─▶ Target Group ─▶ ECS Fargate task (public IP)
│
└─▶ CloudWatch Logs
- App: Express server (
src/server.js) servingpublic/plus/healthand/api/version. - Compute: ECS Fargate service (1 task by default), deployment circuit breaker + automatic rollback enabled.
- Networking: default VPC + default subnets discovered via Terraform data sources; public IP on the task.
- Registry: ECR with immutable tags and scan-on-push.
.
├── src/server.js # Express app: static site + /health + /api/version
├── public/ # Single-page website (HTML/CSS/JS)
├── Dockerfile # Multi-stage, distroless Chainguard image (non-root)
├── .github/workflows/
│ ├── deploy.yml # App CI/CD: OIDC build → ECR → ECS deploy
│ └── terraform.yml # Infra CI/CD: OIDC → terraform plan (PR) / apply (main)
├── bootstrap/ # CloudFormation: manual, one-time prerequisites
│ └── bootstrap.yaml # S3 state bucket + GitHub OIDC provider + deploy roles
├── terraform/ # ECR, IAM (task roles), ALB, ECS, logs, outputs, S3 backend
└── README.md
- An AWS account with a default VPC in your target region (most accounts have one).
- Terraform >= 1.5
- AWS CLI v2, configured (
aws configure) - Docker
- Node.js 20+ (only needed to run the app locally)
- A GitHub repository (for the CI/CD pipeline)
No default VPC? Create one with
aws ec2 create-default-vpc.
npm install
npm start
# open http://localhost:3000Check the endpoints:
curl http://localhost:3000/health
curl http://localhost:3000/api/versionOr build and run the container:
docker build -t dsb-lite:local .
docker run --rm -p 3000:3000 dsb-lite:localThe image is built on Chainguard Node images: a
-devvariant (with npm) for the build stage and the minimal, distroless runtime image. It runs as the non-rootnodeuser (UID 65532) by default and has no shell, sodocker execinto it won't drop you into a prompt — that's expected for a distroless image.
The CI/CD pipeline deploys from your repo into your AWS account, and the OIDC trust is scoped to a GitHub org/user — so you need your own copy first.
- Click Fork at the top of this repository and create the fork under your
own GitHub account or org (e.g.
your-org/dsb-lite). - Enable GitHub Actions on the fork. Forked repos have Actions disabled
by default. Open the fork's Actions tab in the GitHub console and click
"I understand my workflows, go ahead and enable them" — otherwise
deploy.yml/terraform.ymlnever run (and no amount of OIDC setup will trigger them). - Clone your fork locally:
git clone https://github.com/<your-org>/dsb-lite.git cd dsb-lite
- Note your fork's GitHub org/user slug and its numeric org ID — the
bootstrap stack uses them to scope who may assume the deploy roles via OIDC:
gh api /orgs/<your-org> --jq .id # org account gh api /users/<your-user> --jq .id # personal account
Everywhere below that references
devsecblueprint/ org ID156617339, substitute your own fork's slug and ID.
Some things must exist before Terraform runs and back the pipeline that runs Terraform: the S3 state bucket, the GitHub OIDC provider, and the CI deploy roles. These are managed with CloudFormation and deployed manually, once per account:
aws cloudformation deploy \
--template-file bootstrap/bootstrap.yaml \
--stack-name dsb-lite-bootstrap \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides \
ProjectName=dsb-lite \
GitHubOrg=devsecblueprint \
GitHubOrgId=156617339
# Read the outputs you'll need below:
aws cloudformation describe-stacks --stack-name dsb-lite-bootstrap \
--query 'Stacks[0].Outputs' --output tableUse your fork's
GitHubOrgslug andGitHubOrgId(from step 1). The trust policy allows any repo under that org, matched on the immutable org ID so a look-alike org cannot assume the roles. If the GitHub OIDC provider already exists in your account, addCreateOIDCProvider=false.
This creates:
| Output | Meaning |
|---|---|
StateBucketName |
S3 bucket for Terraform state (versioned, encrypted, private) |
AppDeployRoleArn |
Role for deploy.yml → repo variable AWS_ROLE_ARN |
TerraformDeployRoleArn |
Role for terraform.yml → repo variable TF_AWS_ROLE_ARN |
State locking uses S3's native lockfile support (Terraform >= 1.10), so no
DynamoDB table is needed. See bootstrap/README.md for
details (including reusing an existing OIDC provider).
Want purely local state instead? Delete
terraform/backend.tfand run a plainterraform init— but you'll still need the OIDC roles from the stack for CI.
The S3 backend (bucket, key, region) is set statically in
terraform/backend.tf. Before you init, you must
update the bucket value to the StateBucketName the bootstrap stack created
in your account (step 2) — the committed value points at the upstream
account and will not work for you.
Grab the exact name from the stack output:
aws cloudformation describe-stacks --stack-name dsb-lite-bootstrap \
--query "Stacks[0].Outputs[?OutputKey=='StateBucketName'].OutputValue" \
--output text
# -> dsb-lite-tfstate-<your-account-id>Then edit terraform/backend.tf and set that value:
terraform {
backend "s3" {
bucket = "dsb-lite-tfstate-<your-account-id>" # <-- from StateBucketName
key = "dsb-lite/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}The same
bucketvalue is used by theterraform.ymlCI workflow (it also readsbackend.tf), so updating it here fixes both local and CI runs.
cd terraform
cp example.tfvars terraform.tfvars # edit region / sizing if desired
terraform init
terraform applyTerraform prints these outputs:
| Output | Meaning |
|---|---|
ecr_repository_url |
Where images are pushed |
ecs_cluster_name |
ECS cluster name |
ecs_service_name |
ECS service name |
alb_dns_name |
Load balancer DNS name |
application_url |
Public HTTP URL of the site |
On the first
applythere is no image in ECR yet, so the ECS task cannot start until the pipeline (step 6) pushes one. This is expected. The service useslatestas a placeholder until then.
In your forked repo, add a repository variable named AWS_ROLE_ARN set to
the AppDeployRoleArn output from the bootstrap stack:
Settings → Secrets and variables → Actions → Variables → New repository variable
If your region/names differ from the defaults, update the env: block at the
top of .github/workflows/deploy.yml to match your Terraform outputs.
Push to main (or run the workflow manually from the Actions tab). The pipeline:
- Authenticates to AWS via OIDC (no long-lived keys).
- Builds the image and tags it with the commit SHA.
- Pushes it to ECR.
- Registers a new ECS task-definition revision pointing at that image.
- Updates the ECS service and waits for it to stabilize.
Because tags are immutable and keyed to the commit SHA, every deploy is traceable back to the exact source it was built from.
The repo ships with two GitHub Actions workflows, both authenticating to AWS via OIDC (no long-lived keys):
| Workflow | File | Trigger | What it does |
|---|---|---|---|
| App deploy | .github/workflows/deploy.yml |
push to main, manual |
Build image → push to ECR → register task def → update ECS service |
| Infra deploy | .github/workflows/terraform.yml |
changes under terraform/** |
plan on PRs, apply on push to main |
Set these under Settings → Secrets and variables → Actions → Variables:
Both come from the bootstrap stack outputs (step 2):
| Variable | Used by | Bootstrap output |
|---|---|---|
AWS_ROLE_ARN |
deploy.yml | AppDeployRoleArn (ECR + ECS permissions) |
TF_AWS_ROLE_ARN |
terraform.yml | TerraformDeployRoleArn (broader infra permissions) |
The Terraform state bucket is not a repo variable — it's hardcoded in
terraform/backend.tf.
The app-deploy role is deliberately scoped to ECR + ECS. Terraform needs to create/modify ALB, IAM, ECS, ECR, CloudWatch, etc., so give
terraform.ymla separate, broader role viaTF_AWS_ROLE_ARN.
terraform.yml runs fmt, init (against the S3 backend), and validate on
every run. On a pull request it runs terraform plan and posts the plan to
the job summary for review; on push to main (or manual dispatch) it runs
terraform apply.
name: Terraform Infrastructure
on:
push:
branches: [main]
paths: ['terraform/**', '.github/workflows/terraform.yml']
pull_request:
branches: [main]
paths: ['terraform/**', '.github/workflows/terraform.yml']
workflow_dispatch: {}
env:
AWS_REGION: us-east-1
WORKING_DIR: terraform
permissions:
id-token: write
contents: read
pull-requests: write
concurrency:
group: terraform-${{ github.ref }}
cancel-in-progress: false
jobs:
terraform:
name: Terraform ${{ github.event_name == 'pull_request' && 'plan' || 'apply' }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ env.WORKING_DIR }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TF_AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.10.5
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
# Backend bucket/key/region are hardcoded in terraform/backend.tf.
run: terraform init -input=false
- name: Terraform validate
run: terraform validate -no-color
- name: Terraform plan
if: github.event_name == 'pull_request'
run: |
set -o pipefail
terraform plan -input=false -no-color -out=tfplan | tee plan.txt
- name: Publish plan to summary
if: github.event_name == 'pull_request'
run: |
{
echo '### Terraform Plan 📖';
echo '';
echo '```terraform';
tail -c 60000 plan.txt;
echo '```';
} >> "$GITHUB_STEP_SUMMARY"
- name: Terraform apply
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: terraform apply -input=false -auto-approveThis workflow expects the S3 backend bucket and OIDC roles to already exist. Deploy the CloudFormation bootstrap stack once first (see step 2) and set
TF_AWS_ROLE_ARNto its output. The backend bucket is hardcoded interraform/backend.tf.
cd terraform
URL="$(terraform output -raw application_url)"
curl -i "$URL/health" # → 200, {"status":"ok",...}
curl -s "$URL/api/version" # → version, environment, buildTime, gitCommit
open "$URL" # (macOS) open the site in a browserThe footer of the site shows the running version, environment, and short commit
SHA pulled live from /api/version. After a deploy, confirm the gitCommit
matches the commit you just shipped.
Watch the rollout:
aws ecs describe-services \
--cluster "$(terraform output -raw ecs_cluster_name)" \
--services "$(terraform output -raw ecs_service_name)" \
--query 'services[0].deployments'Tasks keep restarting / target group unhealthy
- Confirm the container listens on the port the target group checks (
3000//health). - Check container logs: CloudWatch Logs group
/ecs/dsb-lite. - Ensure the service security group allows inbound from the ALB security group.
Tasks stuck in PENDING or can't pull the image
- The task needs a public IP to reach ECR without a NAT Gateway — this is set
(
assign_public_ip = true). Verify the subnets used are the default (public) subnets. - Confirm an image actually exists in ECR (the first deploy must have run).
Deployment fails and rolls back automatically
- That's the circuit breaker doing its job. New tasks failed health checks, so ECS reverted to the last healthy task definition. Inspect CloudWatch Logs and the failed deployment's stopped-task reason.
GitHub Actions: Not authorized to perform sts:AssumeRoleWithWebIdentity
- Confirm
AWS_ROLE_ARN/TF_AWS_ROLE_ARNmatch the bootstrap stack outputs. - Confirm
GitHubOrg/GitHubOrgIdin the bootstrap stack match your fork's org slug and numeric org ID (the OIDCsubembeds the immutable org ID). - Confirm the workflow has
permissions: id-token: write.
RegisterTaskDefinition errors about unexpected fields
- The workflow strips read-only fields with
jqbefore re-registering. If you customized the task definition, keep that step intact.
ECR push denied
- The image tag already exists (tags are immutable). Since tags are the commit SHA, this only happens if you re-run a deploy for an unchanged commit.
Tear everything down when the workshop ends:
cd terraform
terraform destroyThe ECR repository uses force_delete = true, so it removes any images along
with it. CloudWatch log groups are deleted by Terraform as well. Verify nothing
is left running:
aws ecs list-services --cluster dsb-lite-cluster
aws elbv2 describe-load-balancers --names dsb-lite-albThe bootstrap stack (state bucket, OIDC provider, deploy roles) is separate and
not removed by terraform destroy. The state bucket uses a Delete
deletion policy, so the stack removes it on teardown — but CloudFormation can
only delete an empty bucket. Empty it first (it's versioned), then delete the
stack. See bootstrap/README.md for the full teardown
(including the state backup step):
aws cloudformation delete-stack --stack-name dsb-lite-bootstrapBy design this lab does not create a VPC, subnets, route tables, internet gateway, NAT gateway, custom domain, TLS certificate, database, authentication system, or external API integration. It focuses on the container build and the ECR → ECS → ALB deployment path using existing default networking.
The DevSec Blueprint is a free, open learning platform for practical DevSecOps and cloud security skills. Structured DevSecOps and Cloud Security mastery, built through real systems, not theory.
- Website: https://devsecblueprint.com
- GitHub: https://github.com/devsecblueprint/devsecblueprint
- Discord: https://discord.gg/SkYECC4TD8
- LinkedIn: https://www.linkedin.com/company/devsecblueprint
- X / Twitter: https://x.com/devsecblueprint
- Instagram: https://www.instagram.com/devsecblueprint
MIT