This content originally appeared on DEV Community and was authored by Dom Derrien
My Journey to a Hardened AWS Deployment Pipeline
Over the past few months, I’ve been building and refining a monorepo hosting different parts of my AWS-based application:
• iac/
: Infrastructure as Code using AWS CDK
• serverless/
: Lambda functions with Jest unit tests
• webapp/
: A Vite+Lit single-page application
• cdn/
: Static assets destined for S3
Initially, our pipeline favored speed over control. Merging a pull request into develop
would instantly deploy a new Develop
stack—great for feedback and previews, but risky in the long run.
Production deployments were gated behind manual pull requests from develop
to main
. This informal control worked well, but it relied on our team’s discipline, rather than rule-enforced validation.
As the system grew, I added Dependabot to monitor dependency freshness, but that wasn’t enough. We needed more structure, more checks—and above all, a pipeline that wouldn’t deploy code before it was reviewed.
I decided to implement GitHub Rule Sets and re-architect the GitHub Actions workflows. The goal: validate every meaningful step before merging anything.
These new Rule Sets enforce:
• ✅ Successful builds and tests for any updated project
• 🔍 Security checks: npm audit, ESLint, and static analysis
• 🧠 A cdk diff that compares the PR’s infra against the actual deployed stack, with protection from malicious code
• 🛑 Delayed deployment: no more deploying on PR creation—only after an approved merge
The result? A tighter, safer, and more trustworthy pipeline.
Streamlining Validation: Leveraging Status Checks with Parallel Pipelines
The initial workflow files (one named Develop
for PR created in the develop
branch, and one named Prod
for the prod
branch) were building the code (Lambdas and webapp), running the test suites, packaging the assets, and finally deploying them with the corresponding CDK stack definition.
My first update was to split them into two batches:
- The CI part (building and testing) is triggered when the PR is created against the
develop
branch. - The CD part (building, packaging, and deploying) is triggered when a
push
happens in the PR branch, as a result of a PR merge.
To ease the review process, I wanted to expose the validation checks for each project. To achieve that goal, I decided to define a GitHub Rule Set that will check the status of each validation step.
Notes:
- It's recommended to create a different rule set per responsibility–I set one for core verification, and one for the branch protection.
- To make it as part of the review process, it is suggested to define them with JSON files that are committed to the repository.
- Different from the GitHub Action definitions or the issue templates, these JSON files should be submitted to GitHub via a REST API reachable with the
gh
CLI.- During development, it's suggested to allow repository administrators or identified individuals or teams to bypass the verifications—
actor_id: 5
{
"name": "Core Branch Protection",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["refs/heads/main", "refs/heads/develop"],
"exclude": []
}
},
"rules": [
{
"type": "pull_request"
},
{
"type": "required_status_checks",
"parameters": {
"required_status_checks": [
{
"context": "CI Checks (IaC)"
},
{
"context": "CI Checks (Serverless)"
},
{
"context": "CI Checks (Webapp)"
}
],
"strict_required_status_checks_policy": true
}
},
{
"type": "deletion"
},
{
"type": "non_fast_forward"
}
],
"bypass_actors": [
{
"actor_type": "RepositoryRole",
"actor_id": 5
}
]
}
The workflow defining the validation checks relies on Matrix Strategy: it allows processing a list of steps with { name; command }
in parallel.
name: CI/CD Pipeline
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
permissions:
contents: read
checks: write
pull-requests: read
jobs:
setup:
name: Setup Dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install all dependencies
run: |
npm ci
cd iac && npm ci
cd ../serverless && npm ci
cd ../webapp && npm ci
- name: Cache workspace with dependencies
uses: actions/cache/save@v4
with:
path: .
key: node-modules-${{ runner.os }}-${{ github.sha }}
ci-checks:
name: CI Checks
runs-on: ubuntu-latest
needs: [setup]
strategy:
matrix:
task:
- { task: 'IaC', command: 'cd iac && npm run build && npm test' }
- { task: 'Serverless', command: 'cd serverless && npm run build && npm test' }
- { task: 'Webapp', command: 'cd webapp && npm run build && npm test' }
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache node_modules
uses: actions/cache@v4
with:
path: .
key: node-modules-${{ runner.os }}-${{ github.sha }}
restore-keys: |
node-modules-${{ runner.os }}-
- name: ${{ matrix.task }}
run: ${{ matrix.command }}
cleanup:
name: Cache Cleanup
runs-on: ubuntu-latest
needs: [ci-checks]
if: always()
steps:
- name: Delete workflow cache
run: |
gh cache delete "node-modules-${{ runner.os }}-${{ github.sha }}" --repo ${{ github.repository }} || echo "Cache not found or already deleted"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Notes:
- The first job
setup
installs the project dependencies and creates an entry in the GitHub cache.- The second job
ci-checks
defines a matrix of tasks, each one with a name and a command.- Each task restores the code from the GitHub cache and runs its command
- Unfortunately, there's no way to specify a time-to-live (TTL) for the cache entries. Ideally, I would have set it to 5 minutes as each task takes between 1 and 2 minutes to run. To compensate, the final job calls the
gh
CLI to flush the just-created cache entry.
Guarding Our Gates: Secure Merges and Designated Reviewers
We want developers' contributions to only go through pull requests (PRs). PRs have the benefits of forcing verification steps, involving recently joined developers, and having seasoned developers share their knowledge.
The main advantage of the Rule Sets over the legacy Branch Protection rule is that one set can cover any branches. In our case, we are only interested in protecting the main
and the develop
branches.
{
"name": "Review Requirements",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["refs/heads/main", "refs/heads/develop"],
"exclude": []
}
},
"rules": [
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": false,
"automatic_copilot_code_review_enabled": false,
"allowed_merge_methods": ["merge", "squash", "rebase"]
}
}
],
"bypass_actors": [
{
"actor_type": "RepositoryRole",
"actor_id": 5
}
]
}
This Rule Set definition needs the presence of the CODEOWNERS
file to decide who the required approvers are. This file is useful by itself because it helps involve the best reviewer(s) for each code update.
# Global code owners
* @admin-1 @admin-2
# DevOps
/.github @devops-1
/iac/ @devops-1
# Service developers
# /serverless/ @backend-1
# Frontend developers
# /webapp/ @frontend-1
Smart Security Scanning: Open Source Tools for Targeted Code Review
Even with robust branch protection and automated status checks, a critical layer of defense is ensuring the quality and security of the code itself.
My first reflex was to look at the CodeQL offering from GitHub. However, I quickly looked for an alternative because of the steep cost increase it would represent for our team:
- From $4/user/month for a GitHub Team plan.
- To $53/user/month with the addition of the Advanced Security.
I then decided to focus on powerful yet cost-effective open-source tools:
npm audit
- ESLint with security plugins, and
- Custom regular expressions to identify potential vulnerabilities.
To avoid scanning everything every time, the checks are optimized using dorny/paths-filter@v3
, allowing us to intelligently target our analysis only to the code that has changed.
name: Dependency & Security Review
on:
pull_request:
branches: [develop]
types: [opened, synchronize, reopened]
permissions:
contents: read
checks: write
pull-requests: read
jobs:
# Detect which projects have changes
detect-changes:
name: Detect Project Changes
runs-on: ubuntu-latest
outputs:
iac: ${{ steps.changes.outputs.iac }}
serverless: ${{ steps.changes.outputs.serverless }}
webapp: ${{ steps.changes.outputs.webapp }}
dependencies: ${{ steps.changes.outputs.dependencies }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changes
uses: dorny/paths-filter@v3
id: changes
with:
filters: |
iac:
- 'iac/**'
- 'interfaces/environment/**'
- 'interfaces/resources/**'
serverless:
- 'serverless/**'
- 'interfaces/**'
webapp:
- 'webapp/**'
- 'interfaces/models/**'
dependencies:
- '**/package.json'
- '**/package-lock.json'
- '**/yarn.lock'
security-audit-iac:
name: Security Audit - Infrastructure
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.iac == 'true'
steps:
... # skipped
security-audit-serverless:
name: Security Audit - Serverless
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.serverless == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: |
npm ci
cd serverless && npm ci
- name: Run npm audit (Serverless)
run: |
echo "🔍 Running security audit for Serverless..."
cd serverless
npm audit --audit-level=moderate --production
continue-on-error: true
- name: Run ESLint security check
run: |
echo "🔍 Running ESLint security rules for Serverless..."
# Use root-level ESLint to check serverless code
npx eslint serverless/src/ --ext .ts,.js --format compact || echo "ESLint found potential issues"
continue-on-error: true
- name: Check for known security patterns
run: |
echo "🔍 Checking for common security issues in Serverless..."
# Check for hardcoded secrets
if grep -r -i "password\|secret\|token\|key" serverless/src/ --exclude-dir=node_modules | grep -v "\.d\.ts" | grep -v "console\.log"; then
echo "⚠️ Potential hardcoded secrets found. Please review."
else
echo "✅ No obvious hardcoded secrets found"
fi
# Check for SQL injection patterns
if grep -r -i "SELECT\|INSERT\|UPDATE\|DELETE" serverless/src/ --exclude-dir=node_modules | grep -v "\.d\.ts" | grep "\+\|concat"; then
echo "⚠️ Potential SQL injection patterns found. Please review."
else
echo "✅ No obvious SQL injection patterns found"
fi
security-audit-webapp:
name: Security Audit - Webapp
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.webapp == 'true'
steps:
... # skipped
dependency-analysis:
name: Dependency Analysis
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.dependencies == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Analyze root dependencies
run: |
echo "🔍 Analyzing root project dependencies..."
npm audit --audit-level=moderate || echo "Audit completed with issues"
echo ""
echo "📊 Dependency summary:"
npm ls --depth=0 || echo "Dependency tree generated"
- name: Check for license compliance
run: |
echo "🔍 Checking license compliance..."
# List all licenses
echo "📄 Found licenses:"
npm list --depth=0 --json | jq -r '.dependencies | to_entries[] | "\(.key): \(.value.version)"' || echo "License check completed"
echo ""
echo "ℹ️ Allowed licenses in your workflow: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC"
echo "⚠️ Please manually review any licenses not in the allowed list"
- name: Check dependency freshness
run: |
echo "🔍 Checking for outdated dependencies..."
npm outdated || echo "Outdated check completed"
echo ""
echo "💡 Consider updating outdated dependencies with:"
echo " npm update"
echo " or create a PR to update specific packages"
The Trust Challenge: Safely Reporting Untrusted Infrastructure Previews
Deploying serverless logic or a webapp code can quickly be reverted if something wrong happens. It's a totally different game with the deployed infrastructure.
Branching and Deployment
We use a forking workflow with two main branches in our centralized repository:
develop
: This is our primary development branch. Any code merged into develop is automatically deployed to our development environment on AWS. This is where our QA team does their initial testing.
main
: This branch represents our production-ready code. What's merged into main is automatically deployed to our production environment on AWS.
We manually merge changes from develop into main only after the QA team has thoroughly tested the build in the development environment and given it their approval.
Developers are working with your their forked repository:
- Fork and Clone: Start by forking the main repository and then cloning your personal fork to your local machine.
- Branching for Tasks: To keep your work organized and isolated, create branches out of the
develop
branch from the main repository. - Pushing and Pull Requests: Once changes have been pushed to the fork, create a cross-repository pull request back to the central
develop
branch. - Staying Up-to-Date: To incorporate the latest changes from other developers or after your pull requests have been merged, rebase your local repository with
git pull --rebase upstream develop
, and update your fork withgit push --rebase origin main
The Security Challenge of Infrastructure Code in Forks
When a workflow is created on a PR creation, the code usually runs in the submitter account context thanks to the workflow trigger pull_request
.
But sometimes, the workflow needs to use secrets or authorizations that are only available in the context of the main repository. This can be achieved with the trigger pull_request_target
.
The risk of this workflow is that the submitter, knowingly or not, may have harmful code running as part of the workflow, to steal credentials or to alter the infrastructure.
Dual-Checkout Pattern for Trusted/Untrusted Code Separation
The first step to prevent harmful side-effects, it to checkout the code of the PR in a separate folder (not yet approved, waiting for merge).
The second step is to run only the code from the main repository (already approved and merged). As you can see in the workflow file below, that mean replacing commands like npx tsc
by ../../iac/node_modules/.bin/tsc
, for example.
To list what infrastructure changes to expect in the PR, we rely on the AWS CDK tool with the command cdk diff <branch> --output cdk.out --progress=events
.
Notes:
- The workflow only runs when changes are detected in the project
/iac
.- All operations we can't completely control, like
npm ci
, run in a Docker container.- Using
--output cdk.out
is required otherwise the diffing process will save part of the information in the local directory (untrusted) and another part like themanifest.json
in the parent directory (trusted, where nocdk.out
folder exists).- The cdk-notifier tool is used to expose the upcoming changes in a visual form posted as the comment in the PR.
- The only risk that remains is a malevolent use of the
GITHUB_TOKEN
by thecdk-notifier
. However, the token is short lived and only have the permissions defined in the workflow (pull-requests: write
, etc.).
name: Publish CDK diffs
on:
pull_request_target: # Run in main repo context for status checks
branches: [develop]
types: [opened, synchronize, reopened]
paths:
- 'iac/**'
- '!iac/test/**'
env:
AWS_REGION: --- # obfuscated
AWS_ACCOUNT: --- # obfuscated
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
GITHUB_OWNER: ${{ github.repository_owner }}
GITHUB_REPO: ${{ github.event.repository.name }}
PULL_REQUEST_ID: ${{ github.event.pull_request.number }}
UNTRUSTED_CODE_FOLDER: untrusted-pr-code
jobs:
deploy:
name: check diff to develop
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
issues: write # For commenting on PRs
repository-projects: write
steps:
- name: Checkout Base Branch (Trusted Workflow Definition and Dependencies)
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }} # Checkout the code of the develop branch
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Base Branch Dependencies (Trusted CDK from 'develop')
run: npm ci # This installs your trusted CDK CLI and other base dependencies
- name: Prepare IaC environment (Trusted CDK)
working-directory: ./iac
run: npm ci # This installs your trusted CDK CLI and other base dependencies
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT }}:role/github-ci-role
aws-region: ${{ env.AWS_REGION }}
- name: Check caller identity
run: aws sts get-caller-identity
- name: Checkout PR Head (Untrusted Code)
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
path: ./${{ env.UNTRUSTED_CODE_FOLDER }} # Checkout into a distinct sub-directory
persist-credentials: false # Crucial: Do not carry over git credentials
- name: Prepare Base Branch Dependencies (Untrusted Code from PR)
run: |
docker run --rm \
-v "$(pwd)/$UNTRUSTED_CODE_FOLDER:/workspace:rw" \
-w /workspace \
--user $(id -u):$(id -g) \
node:20-alpine \
npm ci
- name: Prepare IaC environment (Untrusted Code)
run: |
docker run --rm \
-v "$(pwd)/$UNTRUSTED_CODE_FOLDER:/workspace:rw" \
-w /workspace/iac \
--user $(id -u):$(id -g) \
node:20-alpine \
npm ci
- name: Prepare serverless environment
run: |
docker run --rm \
-v "$(pwd)/$UNTRUSTED_CODE_FOLDER:/workspace:rw" \
-w /workspace/serverless \
--user $(id -u):$(id -g) \
node:20-alpine \
npm ci
- name: Compile Webapp
run: |
docker run --rm \
-v "$(pwd)/$UNTRUSTED_CODE_FOLDER:/workspace:rw" \
-w /workspace/webapp \
--user $(id -u):$(id -g) \
node:20-alpine \
sh -c "npm ci && npm run build"
# Check diff the PR code to develop while using the trusted CDK CLI (from the parent folder)
# This ensures that the CDK CLI is trusted and not influenced by untrusted code.
# This is crucial to prevent potential security risks from untrusted code.
- name: Check diff to develop
working-directory: ./${{ env.UNTRUSTED_CODE_FOLDER }}/iac
run: |
echo "- Step 1: TSC"
../../iac/node_modules/.bin/tsc
echo "- Step 2: EsLint"
../../iac/node_modules/.bin/eslint .
echo "- Step 3: CDK diff between PR code and stack 'Develop' live in AWS Cloud"
../../iac/node_modules/.bin/cdk diff Develop --output cdk.out --progress=events &> >(tee cdk.log)
# cdk-notifier
- name: Post CDK diff to PR
run: |
echo "Create cdk-notifier report"
docker run --rm \
-v "$(pwd)/$UNTRUSTED_CODE_FOLDER/iac/cdk.log:/app/cdk.log:ro" \
-e GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}" \
karlderkaefer/cdk-notifier:latest \
--owner $GITHUB_OWNER \
--repo $GITHUB_REPO \
--token "$GITHUB_TOKEN" \
--log-file /app/cdk.log \
--tag-id "diff-pr-$PULL_REQUEST_ID-to-develop" \
--pull-request-id $PULL_REQUEST_ID \
--vcs github \
--ci circleci \
--template extendedWithResources
Lesson Learned
Setting up secure pipelines is hard. It took me 3 days to reach my goals. Without the help of various AI agents, this exercise could have last a month because even with the help of the agents, there were so many things that can be always improved. Just the setup of the infrastructure diffing with the CDK kept me busy during 2 days...
This content originally appeared on DEV Community and was authored by Dom Derrien

Dom Derrien | Sciencx (2025-07-09T18:49:01+00:00) From Chaos to Control: GitHub Rule Sets and Workflows for Safer AWS Deployments. Retrieved from https://www.scien.cx/2025/07/09/from-chaos-to-control-github-rule-sets-and-workflows-for-safer-aws-deployments/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.