Skip to content
devsecblueprintPublic

About

DSB Lite Application for BLK Men In Tech Conference

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

DSB Lite

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.


Architecture

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) serving public/ plus /health and /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.

Repository layout

.
├── 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

Prerequisites

  • 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.


Run locally

npm install
npm start
# open http://localhost:3000

Check the endpoints:

curl http://localhost:3000/health
curl http://localhost:3000/api/version

Or build and run the container:

docker build -t dsb-lite:local .
docker run --rm -p 3000:3000 dsb-lite:local

The image is built on Chainguard Node images: a -dev variant (with npm) for the build stage and the minimal, distroless runtime image. It runs as the non-root node user (UID 65532) by default and has no shell, so docker exec into it won't drop you into a prompt — that's expected for a distroless image.


Deploy to AWS

1. Fork this repository

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.

  1. 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).
  2. 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.yml never run (and no amount of OIDC setup will trigger them).
  3. Clone your fork locally:
    git clone https://github.com/<your-org>/dsb-lite.git
    cd dsb-lite
  4. 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 ID 156617339, substitute your own fork's slug and ID.

2. Deploy the bootstrap stack (manual, one time)

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 table

Use your fork's GitHubOrg slug and GitHubOrgId (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, add CreateOIDCProvider=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.tf and run a plain terraform init — but you'll still need the OIDC roles from the stack for CI.

3. Point the Terraform backend at your state bucket

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 bucket value is used by the terraform.yml CI workflow (it also reads backend.tf), so updating it here fixes both local and CI runs.

4. Provision infrastructure with Terraform

cd terraform
cp example.tfvars terraform.tfvars   # edit region / sizing if desired

terraform init
terraform apply

Terraform 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 apply there is no image in ECR yet, so the ECS task cannot start until the pipeline (step 6) pushes one. This is expected. The service uses latest as a placeholder until then.

5. Configure GitHub Actions

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.

6. Build and deploy the image

Push to main (or run the workflow manually from the Actions tab). The pipeline:

  1. Authenticates to AWS via OIDC (no long-lived keys).
  2. Builds the image and tags it with the commit SHA.
  3. Pushes it to ECR.
  4. Registers a new ECS task-definition revision pointing at that image.
  5. 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.


CI/CD workflows

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

Required repository variables

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.yml a separate, broader role via TF_AWS_ROLE_ARN.

Infrastructure deployment workflow

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-approve

This 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_ARN to its output. The backend bucket is hardcoded in terraform/backend.tf.


Validate the deployment

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 browser

The 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'

Troubleshooting

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_ARN match the bootstrap stack outputs.
  • Confirm GitHubOrg/GitHubOrgId in the bootstrap stack match your fork's org slug and numeric org ID (the OIDC sub embeds the immutable org ID).
  • Confirm the workflow has permissions: id-token: write.

RegisterTaskDefinition errors about unexpected fields

  • The workflow strips read-only fields with jq before 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.

Cleanup

Tear everything down when the workshop ends:

cd terraform
terraform destroy

The 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-alb

The 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-bootstrap

Notes on scope

By 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.


About The DevSec Blueprint

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.

License

MIT

About

DSB Lite Application for BLK Men In Tech Conference

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages