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 type | Suitable deployment target | Pipeline implications |
|---|---|---|
| Vite or React static frontend | S3 and CloudFront | Build files, upload assets, refresh cached entry points |
| Node.js API in a container | Amazon ECS or Azure Container Apps | Build an image, publish it, update the service |
| Kubernetes application | Amazon EKS or Google Kubernetes Engine | Publish an image, apply manifests, verify rollout |
| Native mobile application | App Store Connect or Google Play | Manage signing, platform runners, and distribution tracks |
For this tutorial, confirm these criteria:
- Output:
npm run buildgenerates static files indist/. - 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
maincommits 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, andSITE_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:ListBucketon the deployment bucket.s3:PutObjecton that bucket’s objects.cloudfront:CreateInvalidationandcloudfront:GetInvalidationon 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
--deletehelps 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:
- Retain known-good artifacts in durable, access-controlled storage.
- Select a release using its recorded commit identifier.
- Republish that artifact through the same protected production environment.
- 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_targetworkflows. - 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_modulesindiscriminately: cache the npm download cache, then install cleanly withnpm 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.
Ask the community and get answers from practitioners.