GitHub Actions CI/CD: build, test and deploy to EC2

Build, test and deploy a Node.js app to AWS EC2 with GitHub Actions: OIDC instead of access keys, Docker images in ECR, and releases over SSM instead of SSH.

Most "deploy to EC2 with GitHub Actions" tutorials end the same way: an AWS access key in a repository secret, port 22 open to the internet, and a workflow that SSHes in to run git pull. It works, and it leaves a long-lived key and an open door behind.

This is the setup I'd rather ship. GitHub Actions tests the code and builds a Docker image, pushes it to Amazon ECR, and tells the instance to release it through AWS Systems Manager. No AWS keys stored in GitHub, no SSH port, and every release is one specific, tagged image.

Part of my guide to deploying Node.js to production.

TL;DR#

  • Let GitHub Actions assume an IAM role through OIDC instead of storing AWS access keys.
  • Test first, then build the Docker image once and push it to ECR, tagged with the commit SHA.
  • Release with SSM Run Command: the instance pulls that tag and restarts, with no SSH port open.
  • Wait for the command's result in the workflow, so a failed release fails the run.
  • Gate production with a GitHub environment and a concurrency group, so releases never overlap.

The pipeline at a glance#

push to main
  └─ test      npm ci → lint → test
      └─ release   assume AWS role (OIDC) → build image → push to ECR → SSM: release on EC2

Two jobs, the second gated on the first. If a test fails, nothing is built; if the image fails to build, nothing is deployed.

Step 1: let GitHub assume an AWS role, without access keys#

GitHub Actions can prove who it is to AWS with a short-lived OpenID Connect token. AWS exchanges that token for temporary credentials on an IAM role you control, so there's no access key to leak or rotate.

Add GitHub as an identity provider in IAM once, with the provider URL https://token.actions.githubusercontent.com and the audience sts.amazonaws.com. Then create a role with this trust policy (replace the account ID and repository):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:acme/api:environment:production"
        }
      }
    }
  ]
}

The sub condition is what keeps everyone else out: only jobs from acme/api running in its production environment can assume the role. Then give the role only what the release needs, pushing to one ECR repository and sending commands to one instance:

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": "ecr:GetAuthorizationToken", "Resource": "*" },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:BatchGetImage",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload",
        "ecr:PutImage"
      ],
      "Resource": "arn:aws:ecr:us-east-1:123456789012:repository/api"
    },
    {
      "Effect": "Allow",
      "Action": "ssm:SendCommand",
      "Resource": [
        "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123def4567890",
        "arn:aws:ssm:us-east-1::document/AWS-RunShellScript"
      ]
    },
    { "Effect": "Allow", "Action": "ssm:GetCommandInvocation", "Resource": "*" }
  ]
}

GitHub's guide to configuring OpenID Connect in AWS covers the provider setup step by step.

Step 2: prepare the instance#

Harden it first: Ubuntu server hardening checklist for a fresh EC2 covers SSH, updates, IMDSv2 and Docker's firewall gotcha. Then the instance needs four things:

  1. Docker.
  2. The SSM Agent, which is how Run Command reaches the instance. It comes preinstalled on Amazon Linux 2023 and the Ubuntu Server LTS images.
  3. An instance profile with two AWS-managed policies: AmazonSSMManagedInstanceCore, so Systems Manager can manage it, and AmazonEC2ContainerRegistryReadOnly, so it can pull from ECR. Amazon Linux 2023 also ships the AWS CLI; on Ubuntu, install it.
  4. A release script at /srv/api/deploy.sh that takes the image tag:
#!/usr/bin/env bash
set -euo pipefail

TAG="$1"
REGISTRY=123456789012.dkr.ecr.us-east-1.amazonaws.com
IMAGE="$REGISTRY/api:$TAG"

aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$REGISTRY"
docker pull "$IMAGE"

# docker stop sends SIGTERM, so the old container finishes the requests it's serving.
docker stop api 2>/dev/null || true
docker rm api 2>/dev/null || true
docker run -d --name api --restart unless-stopped \
  --env-file /srv/api/.env -e APP_VERSION="$TAG" \
  -p 127.0.0.1:3000:3000 "$IMAGE"

# Up to 60 seconds to report healthy, or the release fails.
for _ in $(seq 1 30); do
  if curl -fsS http://127.0.0.1:3000/health > /dev/null; then
    echo "Live: $TAG"
    exit 0
  fi
  sleep 2
done

echo "Not healthy after 60 seconds" >&2
docker logs --tail 50 api >&2
exit 1

This script stops the old container before the new one starts, so there's a gap of a few seconds. For no gap at all, use the blue-green script from zero-downtime deploys with Docker and Nginx instead: it starts the new version beside the old one and only switches traffic once it's healthy.

Step 3: the workflow#

Store the role ARN and the instance ID as variables on the production environment (they aren't secrets), then add .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read

# One release at a time; a new push waits instead of cancelling a deploy halfway.
concurrency:
  group: production
  cancel-in-progress: false

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: api

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm test

  release:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write # lets the job request an OIDC token from GitHub
    steps:
      - uses: actions/checkout@v7

      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - id: ecr
        uses: aws-actions/amazon-ecr-login@v2

      - uses: docker/setup-buildx-action@v4

      - uses: docker/build-push-action@v7
        with:
          push: true
          tags: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Release on the instance
        run: |
          INSTANCE_ID="${{ vars.EC2_INSTANCE_ID }}"
          COMMAND_ID=$(aws ssm send-command \
            --instance-ids "$INSTANCE_ID" \
            --document-name AWS-RunShellScript \
            --comment "Release ${{ github.sha }}" \
            --parameters 'commands=["/srv/api/deploy.sh ${{ github.sha }}"]' \
            --query Command.CommandId --output text)

          # Fails the step if the script fails, printing what it wrote.
          if ! aws ssm wait command-executed --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID"; then
            aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" \
              --query '{status: Status, output: StandardOutputContent, errors: StandardErrorContent}'
            exit 1
          fi

A few details that matter:

  • id-token: write is what allows the OIDC exchange. It's granted to the release job only, not the whole workflow.
  • environment: production puts the job in the environment the trust policy names. Add required reviewers or a branch restriction to that environment, and production releases inherit them.
  • The image is tagged with the commit SHA, so every release is traceable to a commit, and rolling back means releasing an older tag.
  • aws ssm wait command-executed checks every 5 seconds and gives up after 20 checks. If your release can take longer than about 100 seconds, poll get-command-invocation in a loop instead.
  • The Docker layer cache (type=gha) keeps rebuilds fast; Docker layer caching in GitHub Actions explains the options.

Run Command executes the script as root, so keep it short, owned by root, and not writable by your app's user.

Rolling back#

Every image in ECR is tagged with its commit, so a rollback is the same script with an older tag. Either re-run the release job from the last good workflow run, or send the command yourself:

aws ssm send-command \
  --instance-ids i-0abc123def4567890 \
  --document-name AWS-RunShellScript \
  --parameters 'commands=["/srv/api/deploy.sh 3f9c2ab8d41e6c07b2f95a1e0c3d7b48e9f6a215"]'

Set an ECR lifecycle policy to expire old images, but keep enough of them to roll back to.

Why not SSH?#

SSH deploys work, and my zero-downtime post uses one because it targets any Linux server. On AWS, Systems Manager is the better fit:

SSH from GitHub Actions SSM Run Command
Inbound port 22, open to GitHub's runner IP ranges None; the agent connects out to AWS
Credentials in GitHub A private SSH key None, with OIDC
Access control Whoever holds the key IAM, scoped to one instance and one document
Audit trail Your own logging Every command recorded in Systems Manager

If you do use SSH, use a dedicated deploy user that can only run the release script, and restrict the key with command= in authorized_keys.

Where this stops being enough#

One instance with a release script is a solid setup for a lot of products. Move on when you need more than one instance behind a load balancer (look at CodeDeploy, which rolls out across an Auto Scaling group, and AWS ALB vs Nginx for the load balancer), or when you'd rather not manage servers at all (ECS on Fargate runs the same image without an instance to patch). AWS infrastructure for small SaaS teams maps out each stage, and do you actually need Kubernetes? covers the step after that.

FAQ#

How do I deploy to EC2 from GitHub Actions without SSH?#

Use AWS Systems Manager Run Command. The SSM Agent on the instance connects out to AWS, so no inbound port is needed: the workflow calls aws ssm send-command with your release script, and aws ssm wait command-executed waits for its result, failing the workflow if the script fails.

Do I need to store AWS access keys in GitHub to deploy to AWS?#

No. Configure GitHub as an OpenID Connect identity provider in IAM and let the workflow assume a role with aws-actions/configure-aws-credentials. Each run gets short-lived credentials, and the role's trust policy limits which repository, branch or environment can use it.

Should I build my Node.js app on the EC2 instance?#

No. Building on the server uses its CPU and memory while it's serving traffic, and the result can differ from what you tested. Build a Docker image once in CI, push it to a registry like ECR, and have the server pull and run that exact image.

How do I roll back a deploy to EC2?#

If every image is tagged with its commit SHA, run the release script again with the tag of the last good commit, either by re-running that workflow run's release job or by sending the command with aws ssm send-command. The instance pulls the older image and restarts on it.

Muhammad Huzaifa Abdulahad
Written by

Muhammad Huzaifa Abdulahad

Full-Stack Software & DevOps Engineer

I build SaaS platforms with Node.js, NestJS, TypeScript and PostgreSQL, and run them on Linux, Docker, AWS and CI/CD — from the first line of code to production.

Related articles

See all posts →

Have a complex idea? Let's build it together.

From MVPs to scalable products, let's turn your idea into reliable software built for real-world impact.