GUIDE TUTORIALS

Set up a CI/CD pipeline with GitHub Actions

Build a GitHub Actions pipeline that validates a Vite application and deploys it to AWS without permanent cloud credentials. Follow the workflow, permission model, release controls, and rollback strategy.

What this GitHub Actions pipeline will deliver

To set up a ci/cd pipeline with github actions, you need more than a workflow that turns green: you need trustworthy checks, controlled deployment permissions, and a recoverable release process. This tutorial builds those foundations for a Node.js application using Vite, Vitest, Amazon S3, and Amazon CloudFront.

The example deploys a static frontend, not a server-side application. Pull requests run validation; changes merged into main produce a deployable artifact; an approved production job publishes that artifact to AWS using short-lived credentials.

For decision-makers, this design keeps automation close to the source repository and avoids operating a separate CI server. For practitioners, it provides a concrete starting point with explicit security and deployment boundaries.

Choose the pipeline architecture before writing YAML

GitHub Actions is a strong fit when your repositories, code reviews, and access controls already live in GitHub. Its workflows support both straightforward deployment pipelines and larger systems with reusable workflows and self-hosted runners.

However, the deployment target determines much of the implementation.

Application typeSuitable deployment targetPipeline implications
Vite or React static frontendS3 and CloudFrontBuild files, upload assets, refresh cached entry points
Node.js API in a containerAmazon ECS or Azure Container AppsBuild an image, publish it, update the service
Kubernetes applicationAmazon EKS or Google Kubernetes EnginePublish an image, apply manifests, verify rollout
Native mobile applicationApp Store Connect or Google PlayManage signing, platform runners, and distribution tracks

For this tutorial, confirm these criteria:

  • Output: npm run build generates static files in dist/.
  • Testing: tests run unattended and return a nonzero exit code on failure.
  • Hosting: an existing CloudFront distribution serves a private S3 bucket.
  • Access: you can create an AWS IAM role and configure GitHub environments.
  • Release policy: only protected main commits are eligible for production.

A static S3 deployment will not host Next.js server-side rendering, API routes, or long-running Node.js processes. Those require a different deployment target.

Understand the cost and operational trade-offs

GitHub-hosted runners reduce maintenance, but usage allowances and charges depend on your account and repository configuration. Linux runners are usually the practical default for this stack. Self-hosted runners offer network access and customization, but you become responsible for isolation, patching, capacity, and cleanup.

AWS adds storage, requests, data transfer, and potentially CloudFront invalidation charges. Review the GitHub Actions billing documentation alongside your AWS cost model before expanding to many repositories.

Step 1: Make local validation reproducible

Start with a Vite application that already includes meaningful Vitest tests. Add predictable scripts to package.json, preserving your existing dependencies and settings:

```json

{

"scripts": {

"dev": "vite",

"lint": "eslint .",

"test:ci": "vitest run",

"build": "vite build"

}

}

```

Install and configure ESLint and Vitest if they are not already present. The workflow below assumes both are operational; scripts alone do not configure these tools.

Commit package-lock.json, then verify locally:

```bash

npm ci

npm run lint

npm run test:ci

npm run build

```

Use npm ci, not npm install, in CI. It installs from the committed lockfile and fails when that lockfile disagrees with package.json.

Use the same supported Node.js major version locally and in automation. This example uses Node.js 22. A version file or development container can reduce differences between developer machines and runners.

Do not configure the test runner to pass when no tests exist merely to make the pipeline green. At minimum, test a meaningful application behavior before enabling automatic releases.

Step 2: Prepare AWS hosting and GitHub configuration

Create the infrastructure before running the deployment workflow:

  • An S3 bucket with public access blocked.
  • A CloudFront distribution using the bucket’s REST endpoint.
  • CloudFront Origin Access Control and a bucket policy allowing that distribution to read objects.
  • A default root object such as index.html.
  • HTTPS configuration and, optionally, a custom domain.

Single-page applications also need an intentional routing strategy. A direct visit to /settings must resolve appropriately rather than returning an S3 access error. CloudFront Functions can rewrite application routes to index.html; avoid rewriting missing JavaScript or image requests into HTML.

In GitHub, create an environment named production. Configure:

  • A deployment branch rule allowing main.
  • Required reviewers, if supported by your plan and repository visibility.
  • Environment variables named AWS_ROLE_ARN, AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID, and SITE_URL.

Use the full HTTPS address for SITE_URL. These values are configuration identifiers, not AWS secret keys.

An approval gate makes this continuous delivery: releases are ready automatically but require authorization. Removing that gate creates continuous deployment after successful checks.

Step 3: Configure AWS authentication with OIDC

Avoid storing permanent AWS access keys in GitHub. OpenID Connect, or OIDC, lets a workflow exchange its GitHub identity for temporary AWS credentials.

Create the GitHub OIDC identity provider in AWS IAM using:

  • Provider URL: https://token.actions.githubusercontent.com
  • Audience: sts.amazonaws.com

Then create an IAM role whose trust policy accepts only your repository’s production environment:

```json

{

"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:YOUR_ORG/YOUR_REPO:environment:production"

}

}

}

]

}

```

Replace the account ID, organization, and repository values. Because the subject names an environment rather than a branch, the environment’s deployment branch rule is an essential control.

Give this role narrowly scoped permissions:

  • s3:ListBucket on the deployment bucket.
  • s3:PutObject on that bucket’s objects.
  • cloudfront:CreateInvalidation and cloudfront:GetInvalidation on the target distribution.

Additional permissions may be needed if you introduce customer-managed encryption keys or other storage controls. Do not attach AdministratorAccess as a shortcut.

Follow GitHub’s official AWS OIDC configuration guide when implementing the identity-provider and trust relationship.

Step 4: Add the GitHub Actions workflow

Create .github/workflows/ci-cd.yml:

```yaml

name: Validate and deploy

on:

pull_request:

branches: [main]

push:

branches: [main]

permissions:

contents: read

jobs:

validate:

runs-on: ubuntu-24.04

steps:

  • uses: actions/checkout@v4
  • uses: actions/setup-node@v4

with:

node-version: "22"

cache: npm

  • name: Install dependencies

run: npm ci

  • name: Lint

run: npm run lint

  • name: Test

run: npm run test:ci

  • name: Build

run: npm run build

  • name: Record release

run: printf '%s\n' "$GITHUB_SHA" > dist/release.txt

  • name: Upload deployment artifact

if: github.event_name == 'push'

uses: actions/upload-artifact@v4

with:

name: site-${{ github.sha }}

path: dist/

if-no-files-found: error

retention-days: 14

deploy:

if: github.event_name == 'push' && github.ref == 'refs/heads/main'

needs: validate

runs-on: ubuntu-24.04

environment: production

concurrency:

group: production-deploy

cancel-in-progress: false

permissions:

contents: read

id-token: write

env:

AWS_REGION: ${{ vars.AWS_REGION }}

S3_BUCKET: ${{ vars.S3_BUCKET }}

DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}

SITE_URL: ${{ vars.SITE_URL }}

steps:

  • name: Download validated artifact

uses: actions/download-artifact@v4

with:

name: site-${{ github.sha }}

path: dist

  • name: Authenticate to AWS

uses: aws-actions/configure-aws-credentials@v4

with:

role-to-assume: ${{ vars.AWS_ROLE_ARN }}

aws-region: ${{ env.AWS_REGION }}

  • name: Publish assets before entry points

shell: bash

run: |

set -euo pipefail

aws s3 sync dist/ "s3://$S3_BUCKET/" \

--exclude "index.html" \

--exclude "release.txt" \

--cache-control "public,max-age=300"

aws s3 cp dist/index.html \

"s3://$S3_BUCKET/index.html" \

--cache-control "no-cache"

aws s3 cp dist/release.txt \

"s3://$S3_BUCKET/release.txt" \

--cache-control "no-cache"

  • name: Refresh CloudFront

shell: bash

run: |

set -euo pipefail

INVALIDATION_ID=$(aws cloudfront create-invalidation \

--distribution-id "$DISTRIBUTION_ID" \

--paths "/*" \

--query 'Invalidation.Id' \

--output text)

aws cloudfront wait invalidation-completed \

--distribution-id "$DISTRIBUTION_ID" \

--id "$INVALIDATION_ID"

  • name: Verify production

shell: bash

run: |

set -euo pipefail

curl --fail --silent --show-error \

--retry 5 --retry-all-errors --max-time 30 \

"${SITE_URL%/}/" > /dev/null

RELEASE=$(curl --fail --silent --show-error \

--retry 5 --retry-all-errors --max-time 30 \

"${SITE_URL%/}/release.txt")

test "$RELEASE" = "$GITHUB_SHA"

```

The example uses readable major-version action tags. Before production adoption, pin external actions to reviewed full commit SHAs and use Dependabot to propose updates. Tags can move; immutable references make dependency changes explicit.

GitHub’s workflow syntax reference documents the events, permissions, conditions, and concurrency controls used here.

Step 5: Understand the deployment guarantees

The deployment job downloads the artifact produced by validation. It does not run another dependency installation or rebuild the frontend. This reduces the chance that production receives something different from the validated build.

The release marker ties the deployed files to a commit. It is useful evidence, but not a substitute for application-level smoke tests.

Several implementation choices deserve attention:

  • Assets upload first: the new HTML should not reference files that have not arrived yet.
  • Old assets remain: omitting --delete helps previously loaded pages continue fetching older hashed bundles.
  • Cache settings are conservative: use year-long immutable caching only for genuinely content-hashed assets.
  • Invalidation completes before verification: otherwise a successful request might still reach an older cached release.

Configure CloudFront’s cache policy with a minimum TTL of zero for content that must respect no-cache. A positive minimum TTL can override the intended behavior of origin cache headers.

This is not an atomic deployment: multiple files change over time. Applications requiring stronger consistency should use release-specific prefixes and a controlled origin switch, or a platform with atomic deployment support.

Production concurrency prevents overlapping publication jobs, but it is not a guaranteed FIFO release queue. Pending runs can be replaced, and approvals can complicate ordering. Approve only the intended release, cancel obsolete runs, and add a branch-head check if deploying anything except the newest commit is unacceptable.

Step 6: Enforce checks and rehearse recovery

Open a pull request that intentionally fails a test. Confirm that validation fails and no deployment starts. Then fix it and verify the full production path after merging.

Configure a GitHub ruleset or branch protection rule that requires the validation check before merging into main. Restrict bypass permissions and review changes to .github/workflows/ through CODEOWNERS.

For stronger production confidence, add Playwright checks covering critical user journeys. A successful HTTP response only proves that an endpoint returned successfully, not that authentication, navigation, or backend integration works.

Establish a rollback procedure

The simplest recovery is to revert the faulty commit through a pull request and deploy the resulting build. That restores source behavior, but it is a new build, not an exact restoration of the old artifact.

For exact rollback:

  1. Retain known-good artifacts in durable, access-controlled storage.
  2. Select a release using its recorded commit identifier.
  3. Republish that artifact through the same protected production environment.
  4. Invalidate CloudFront and rerun smoke tests.

The workflow’s 14-day artifact retention is only an example. Set retention according to release cadence and incident-response needs. Enable S3 versioning as an additional recovery layer, but remember that individual object versions do not constitute a coordinated application release.

Common mistakes that weaken the pipeline

  • Exposing credentials to pull requests: keep cloud authentication in the protected deployment job. Avoid executing untrusted PR code with privileged pull_request_target workflows.
  • Treating frontend variables as secrets: Vite client-side values become visible in shipped JavaScript. API keys that must remain private belong on a backend.
  • Caching node_modules indiscriminately: cache the npm download cache, then install cleanly with npm ci.
  • Deleting old bundles immediately: open browser sessions may still request assets from the previous release. Use a separate, age-aware cleanup process.
  • Ignoring partial deployment failure: an upload can modify production before a later step fails. Investigate the live site and invoke rollback when necessary.
  • Granting broad IAM permissions: separate infrastructure provisioning privileges from routine publication privileges.

Frequently asked questions

Can I use GitHub Actions without AWS?

Yes. Keep the validation and artifact stages, then replace authentication and publication with your provider’s deployment mechanism. Azure, Google Cloud, Cloudflare Pages, and Vercel are common alternatives. Check whether the provider supports federated identity or requires a scoped deployment token.

Should tests and deployment use separate workflows?

Not necessarily. A single workflow makes the dependency between validation and deployment easy to inspect. Separate workflows become useful for independent release schedules or reusable deployment automation, but require careful artifact provenance and permission boundaries.

How should I add staging?

Create a separate GitHub environment, AWS role, bucket, and distribution. Deploy to staging, run integration checks, then promote the same artifact to production. If configuration is embedded during the frontend build, introduce runtime configuration or explicitly accept that environment-specific builds are different artifacts.

What should I measure after launch?

Track validation duration, flaky test failures, approval delays, deployment failures, and recovery time. Record the deployed commit and links to the relevant workflow run. These signals show whether the pipeline improves release reliability rather than merely automating uploads.

Turn the workflow into a release contract

A useful pipeline establishes a clear contract: reviewed source passes reproducible checks, a traceable artifact reaches production through restricted permissions, and the team can recover when verification fails.

Start with this single-application workflow, rehearse failure scenarios, and introduce reusable workflows or additional environments only when their benefits justify the complexity. For related deployment and engineering walkthroughs, browse more Tutorials topics.

Have a question about this topic?

Ask the community and get answers from practitioners.

Start a discussion