AWS SAM with Java: Part 3 - GitHub Actions CI/CD

AWS

This is Phase 3 of the AWS SAM Java series. By the end of Phase 2 you had a working Java REST API deployed to isolated dev and prod environments on real AWS. Every deployment was still a manual sam deploy command. In this phase you automate the entire process with GitHub Actions so every code push triggers a full build, test, and deploy pipeline.

By the end of this phase you will never run sam deploy manually again.


What You Are Building

Developer pushes code
        ↓
GitHub Actions triggers
        ↓
Maven compiles and runs unit tests
        ↓
sam build packages the fat jar
        ↓
sam deploy β†’ dev stack
        ↓
Integration tests hit the live dev API
        ↓
Manual approval gate
        ↓
sam deploy β†’ prod stack
        ↓
Live on API Gateway

Three workflows power this:

WorkflowTriggerPurpose
ci.ymlEvery pull requestFast feedback loop before merge
deploy-dev.ymlEvery merge to mainDeploy to dev and run integration tests
deploy-prod.ymlAfter dev passesDeploy to prod with manual approval

Step 1: OIDC Authentication (No AWS Keys in GitHub)

This is the most important step in Phase 3. The wrong way to give GitHub Actions access to AWS is to create an IAM user, generate access keys, and paste them into GitHub Secrets. Those keys are long lived credentials that never expire and can leak.

The right way is OIDC. GitHub Actions proves its identity to AWS using a short lived signed token. AWS verifies it and issues temporary credentials that expire in 15 minutes. No keys stored anywhere.

How OIDC Works

GitHub Actions Runner
        β”‚
        β”‚  "I am workflow X on repo Y, here is my signed JWT"
        ↓
AWS STS (Security Token Service)
        β”‚
        β”‚  Verifies JWT against GitHub's OIDC provider
        β”‚  Checks the trust policy on the IAM role
        ↓
Issues temporary credentials (15 min lifetime)
        β”‚
        ↓
GitHub Actions uses them for sam deploy

Why OIDC? No long lived credentials to rotate or leak. AWS only trusts tokens from your specific repository. Credentials expire automatically after 15 minutes.

Set Up the OIDC Provider in AWS

Run this once. It tells AWS to trust GitHub’s identity tokens:

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

Create the IAM Role for GitHub Actions

Create a file called github-actions-trust-policy.json on your Desktop.

Replace YOUR_ACCOUNT_ID and YOUR_GITHUB_USERNAME before running:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_USERNAME/my-first-api:*"
        }
      }
    }
  ]
}

The Condition block is critical. It ensures only your specific repo can assume this role. Without it any GitHub Actions workflow could request credentials.

Get your account ID:

aws sts get-caller-identity --query Account --output text

Create the role:

aws iam create-role \
  --role-name github-actions-sam-role \
  --assume-role-policy-document file://~/Desktop/github-actions-trust-policy.json

Attach the permissions this role needs to deploy SAM applications:

aws iam attach-role-policy \
  --role-name github-actions-sam-role \
  --policy-arn arn:aws:iam::aws:policy/AWSCloudFormationFullAccess

aws iam attach-role-policy \
  --role-name github-actions-sam-role \
  --policy-arn arn:aws:iam::aws:policy/AWSLambda_FullAccess

aws iam attach-role-policy \
  --role-name github-actions-sam-role \
  --policy-arn arn:aws:iam::aws:policy/AmazonAPIGatewayAdministrator

aws iam attach-role-policy \
  --role-name github-actions-sam-role \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

aws iam attach-role-policy \
  --role-name github-actions-sam-role \
  --policy-arn arn:aws:iam::aws:policy/IAMFullAccess

aws iam attach-role-policy \
  --role-name github-actions-sam-role \
  --policy-arn arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess

Get the role ARN. You will need it in every workflow file:

aws iam get-role \
  --role-name github-actions-sam-role \
  --query 'Role.Arn' \
  --output text

Save the output. It looks like:

arn:aws:iam::123456789012:role/github-actions-sam-role

Step 2: Push Your Project to GitHub

Before writing workflows you need your project in a GitHub repository.

cd ~/Desktop/my-first-api

# Initialize git
git init
git add .
git commit -m "Initial commit: Phase 1 and 2 complete"

Create a new repository on GitHub at github.com/new. Name it my-first-api, keep it private, and do not add a README or .gitignore.

Then push:

git remote add origin https://github.com/YOUR_USERNAME/my-first-api.git
git branch -M main
git push -u origin main

Step 3: Add .gitignore

Make sure you are not committing build artifacts or credentials:

cat > ~/Desktop/my-first-api/.gitignore << 'EOF'
# SAM build output
.aws-sam/

# Maven build output
HelloWorldFunction/target/

# AWS credentials - never commit these
.aws/credentials

# IntelliJ
.idea/
*.iml

# Mac
.DS_Store
EOF

Step 4: Create the Three Workflows

Create the GitHub Actions directory:

mkdir -p ~/Desktop/my-first-api/.github/workflows

Workflow 1: CI on Every Pull Request

This runs on every PR. It compiles, tests, and builds but does not deploy anything. Its job is to be a fast feedback loop. Fail early before anything reaches AWS.

Create .github/workflows/ci.yml:

name: CI

on:
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    name: Build and Test
    runs-on: ubuntu-latest

    permissions:
      id-token: write   # required for OIDC token generation
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'corretto'

      - name: Set up SAM CLI
        uses: aws-actions/setup-sam@v2

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-sam-role
          aws-region: us-east-1

      - name: Run unit tests
        run: |
          cd HelloWorldFunction
          mvn test

      - name: SAM build
        run: sam build

      - name: Validate SAM template
        run: sam validate

Workflow 2: Deploy to Dev on Merge to Main

This runs when a PR merges into main. It deploys to the dev stack then runs integration tests against the live dev endpoint. If integration tests fail the prod workflow never triggers.

Create .github/workflows/deploy-dev.yml:

name: Deploy to Dev

on:
  push:
    branches: [main]

jobs:
  deploy-dev:
    name: Deploy to Dev
    runs-on: ubuntu-latest

    permissions:
      id-token: write
      contents: read

    outputs:
      api-endpoint: ${{ steps.get-endpoint.outputs.endpoint }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'corretto'

      - name: Set up SAM CLI
        uses: aws-actions/setup-sam@v2

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-sam-role
          aws-region: us-east-1

      - name: Run unit tests
        run: |
          cd HelloWorldFunction
          mvn test

      - name: SAM build
        run: sam build

      - name: Deploy to dev
        run: sam deploy --config-env dev --no-confirm-changeset --no-fail-on-empty-changeset

      - name: Get API endpoint
        id: get-endpoint
        run: |
          ENDPOINT=$(aws cloudformation describe-stacks \
            --stack-name my-first-api-dev \
            --query 'Stacks[0].Outputs[?OutputKey==`ApiEndpoint`].OutputValue' \
            --output text)
          echo "endpoint=$ENDPOINT" >> $GITHUB_OUTPUT
          echo "Dev API endpoint: $ENDPOINT"

      - name: Run integration tests
        run: |
          ENDPOINT="${{ steps.get-endpoint.outputs.endpoint }}"
          echo "Testing endpoint: $ENDPOINT"

          # Test 1: basic request returns 200
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" $ENDPOINT/42)
          if [ "$STATUS" != "200" ]; then
            echo "FAIL: Expected 200 but got $STATUS"
            exit 1
          fi
          echo "PASS: GET /users/42 returned 200"

          # Test 2: response contains userId field
          BODY=$(curl -s $ENDPOINT/42)
          if ! echo $BODY | grep -q "userId"; then
            echo "FAIL: Response missing userId field"
            echo "Body: $BODY"
            exit 1
          fi
          echo "PASS: Response contains userId"

          # Test 3: response contains the correct value
          if ! echo $BODY | grep -q "42"; then
            echo "FAIL: Response missing expected userId value"
            exit 1
          fi
          echo "PASS: Response contains correct userId value"

          echo "All integration tests passed"

Workflow 3: Deploy to Prod with Approval Gate

This triggers after the dev workflow succeeds. It pauses at the environment: production block and waits for a human to click approve in GitHub before touching prod.

Create .github/workflows/deploy-prod.yml:

name: Deploy to Prod

on:
  workflow_run:
    workflows: ["Deploy to Dev"]
    types: [completed]
    branches: [main]

jobs:
  deploy-prod:
    name: Deploy to Prod
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    environment:
      name: production        # triggers the approval gate configured in GitHub Settings

    permissions:
      id-token: write
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'corretto'

      - name: Set up SAM CLI
        uses: aws-actions/setup-sam@v2

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-sam-role
          aws-region: us-east-1

      - name: SAM build
        run: sam build

      - name: Deploy to prod
        run: sam deploy --config-env prod --no-confirm-changeset --no-fail-on-empty-changeset

      - name: Get prod endpoint
        id: get-endpoint
        run: |
          ENDPOINT=$(aws cloudformation describe-stacks \
            --stack-name my-first-api-prod \
            --query 'Stacks[0].Outputs[?OutputKey==`ApiEndpoint`].OutputValue' \
            --output text)
          echo "Prod API endpoint: $ENDPOINT"

      - name: Smoke test prod
        run: |
          ENDPOINT=$(aws cloudformation describe-stacks \
            --stack-name my-first-api-prod \
            --query 'Stacks[0].Outputs[?OutputKey==`ApiEndpoint`].OutputValue' \
            --output text)

          STATUS=$(curl -s -o /dev/null -w "%{http_code}" $ENDPOINT/1)
          if [ "$STATUS" != "200" ]; then
            echo "FAIL: Prod smoke test failed with status $STATUS"
            exit 1
          fi
          echo "PASS: Prod smoke test passed"

Step 5: Set Up the Production Approval Gate

The environment: production block in the prod workflow pauses the deploy and sends you an approval request. No one can deploy to prod without a human reviewing and clicking approve.

Set it up in GitHub:

  1. Go to your repository on GitHub
  2. Click Settings β†’ Environments β†’ New environment
  3. Name it production (this must match exactly what is in deploy-prod.yml)
  4. Enable Required reviewers and add yourself
  5. Click Save protection rules

Every prod deploy will now pause, send you an email notification, and wait for your approval before proceeding.


Step 6: Replace YOUR_ACCOUNT_ID in All Workflow Files

Rather than editing each file manually, use this to replace the placeholder in all three files at once:

cd ~/Desktop/my-first-api

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

sed -i '' "s/YOUR_ACCOUNT_ID/$ACCOUNT_ID/g" \
  .github/workflows/ci.yml \
  .github/workflows/deploy-dev.yml \
  .github/workflows/deploy-prod.yml

echo "Replaced account ID with: $ACCOUNT_ID"

Verify it worked:

grep "role-to-assume" .github/workflows/ci.yml
# Should show your real account ID, not YOUR_ACCOUNT_ID

Step 7: Push and Watch the Pipeline Run

Commit everything and push:

cd ~/Desktop/my-first-api
git add .
git commit -m "Add GitHub Actions CI/CD pipelines with OIDC auth"
git push origin main

Go to your GitHub repo and click the Actions tab. You will see the Deploy to Dev workflow running. Click into it to watch each step execute in real time:

βœ“ Checkout code
βœ“ Set up Java 21
βœ“ Set up SAM CLI
βœ“ Configure AWS credentials via OIDC
βœ“ Run unit tests
βœ“ SAM build
βœ“ Deploy to dev
βœ“ Get API endpoint
βœ“ Run integration tests

When dev succeeds, the Deploy to Prod workflow appears as waiting for approval. Click Review deployments, select production, and click Approve and deploy. Prod deploys automatically.


Step 8: Test the Full Pipeline End to End

This is the moment that ties everything together. Create a feature branch, make a change, and raise a PR:

git checkout -b feature/add-greeting

Open App.java and update the message:

Map<String, Object> body = Map.of(
    "message", "Hello from Lambda, deployed via CI/CD",
    "userId",  userId,
    "env",     env,
    "table",   tableName
);

Push and raise a pull request:

git add .
git commit -m "Update greeting message"
git push origin feature/add-greeting

Go to GitHub and open a pull request from feature/add-greeting into main. Watch what happens automatically:

1. CI workflow triggers on the PR
   β†’ unit tests run
   β†’ sam build runs
   β†’ sam validate runs
   β†’ PR shows green checkmark

2. Merge the PR

3. Deploy to Dev triggers on merge
   β†’ unit tests run again
   β†’ sam build runs
   β†’ sam deploy pushes to dev stack
   β†’ integration tests hit the live dev API
   β†’ all three tests pass

4. Deploy to Prod triggers after dev passes
   β†’ pauses for approval
   β†’ you receive an email notification
   β†’ you click Approve
   β†’ sam deploy pushes to prod stack
   β†’ smoke test confirms prod is healthy

Your updated message is now live on both environments without you running a single command.


The Complete Branch Strategy

BranchTriggersDeploys To
feature/*CI on PR open/updateNothing
main (on merge)Deploy to Devdev stack
main (after dev)Deploy to Prod (+ approval)prod stack

This means:

  • Developers work on feature branches and open PRs
  • Every PR gets fast feedback from the CI workflow
  • Merging to main is the only gate to dev
  • Prod is always protected by both integration tests and a human approval

Common Errors and Fixes

Credentials could not be loaded

The OIDC provider is not set up or the trust policy has the wrong repo name. Verify the sub condition in your trust policy matches repo:YOUR_USERNAME/my-first-api:* exactly.

sam deploy fails with No changes to deploy

Add --no-fail-on-empty-changeset to the deploy command. This tells SAM to exit successfully even when there is nothing new to deploy, which happens when you push a non infrastructure change.

Prod workflow does not trigger after dev succeeds

The workflow_run trigger only fires on the default branch. Make sure your default branch in GitHub is set to main and not master.

Approval gate does not appear

The environment name in deploy-prod.yml must match the environment name you created in GitHub Settings exactly. Both must be production.

sam validate fails in CI

SAM validate needs AWS credentials to resolve {{resolve:ssm:...}} references. Make sure the OIDC configure step runs before the validate step.


Phase 3 Summary

Here is everything you built in this phase:

StepWhat You Did
Step 1Set up OIDC so GitHub Actions assumes an IAM role with no stored keys
Step 2Pushed the project to GitHub
Step 3Added .gitignore to keep build artifacts and credentials out of git
Step 4Created three workflow files: CI, deploy dev, deploy prod
Step 5Configured the production approval gate in GitHub Settings
Step 6Replaced account ID placeholders across all workflow files
Step 7Pushed and watched the first automated pipeline run
Step 8Tested the full end to end flow with a feature branch and PR

Your pipeline now enforces this flow for every single code change:

Write code β†’ Open PR β†’ CI passes β†’ Merge β†’ Dev deploys
β†’ Integration tests pass β†’ Approve β†’ Prod deploys

Key Takeaways

  • Never store AWS access keys in GitHub. Use OIDC for secure, keyless authentication
  • Three workflows cover the full lifecycle: CI for PRs, deploy dev on merge, deploy prod with approval
  • The environment: production block in GitHub Actions creates a manual approval gate
  • Integration tests run against the live dev API before prod is even considered
  • Use --no-fail-on-empty-changeset to handle deploys with no infrastructure changes
  • The workflow_run trigger chains workflows together automatically

Next: Phase 4

In Phase 4 you move from working to production grade. The topics are:

Cold Start Tuning: Java Lambda cold starts can take 2 to 5 seconds. SnapStart reduces this to under 200ms for most cases. Provisioned Concurrency eliminates it entirely for critical paths.

Error Handling: Dead letter queues, Lambda destinations, and retry configuration so failed invocations never silently disappear.

API Best Practices: Request validation at the API Gateway level, CORS hardening, throttling, and usage plans so your API is safe to expose publicly.

Cost Optimization: Right sizing memory, switching to arm64 architecture, and using the Lambda Power Tuning tool to find the optimal memory setting for your specific workload.

Security: WAF rules on API Gateway, VPC Lambda configuration, and tightening IAM roles from the broad policies used in Phase 3 to true least privilege.

Comments

Join the discussion and share your thoughts