CI only matters if it runs something real. Most tutorials show either bloated pipelines or toy examples that verify nothing. I prefer one workflow, one runner, two commands, and a green check.

This post is the workflow file I use as a baseline.

Why I set this up first

Without CI, I am the only person verifying the build. The moment another OS, another Node version, or another contributor enters the picture, assumptions break. CI gives me feedback in a clean environment every time I push (Source).

GitHub Actions is just the platform. It spins up a temporary machine, checks out my code, runs the commands I tell it to run, and reports pass or fail (Source). Nothing more mysterious than that.

Here is what GitHub Actions gives you out of the box:

  • Temporary runners — clean VMs that are destroyed after each job (Source)
  • Event triggers — workflows run on push, pull request, schedule, or manual dispatch (Source)
  • Pass/fail reporting — a green check or red X visible on every commit
  • Job logs — full stdout/stderr for every step, viewable in the Actions tab
Cloud architecture checkpoint illustration for this section.

The workflow file

Create .github/workflows/ci.yml:

name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run lint
- run: npm run build

That is it. actions/checkout pulls the repo (Source). actions/setup-node installs Node (Source). The three run lines execute the same commands I run locally. If any exit non-zero, the workflow fails (Source).

Here is what each step in the workflow does:

  • actions/checkout@v4 — clones your repository onto the runner so subsequent steps can access your files
  • actions/setup-node@v4 — installs the specified Node.js version (20 in this case) and caches npm dependencies
  • npm install — installs all dependencies from package.json
  • npm run lint — runs your linter (e.g. ESLint) to catch style and syntax errors
  • npm run build — runs your production build to verify the project compiles cleanly

Extending it with AWS

The baseline above is CI only — it verifies the build. When I need to actually deploy, I extend the same workflow with AWS steps. The services I use and why:

  • S3 — hosts the static build output. Cheaper and simpler than running a web server. The build artifacts go to a bucket configured for static website hosting.
  • CloudFront — CDN in front of S3. Caches at the edge and gives me HTTPS without managing certificates on the origin.
  • IAM OIDC — lets GitHub Actions authenticate to AWS without long-lived access keys. You set up an identity provider in IAM, create a role with a trust policy scoped to your repo, and the workflow assumes that role using aws-actions/configure-aws-credentials.

Here is the deploy portion I add after the build step:

deploy:
needs: build
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
- run: aws s3 sync public/ s3://my-bucket --delete
- run: aws cloudfront create-invalidation --distribution-id E1ABCDEF23 --paths "/*"

The permissions: id-token: write line is required for OIDC. Without it, the configure-aws-credentials action cannot request the temporary credentials.

How I verify it works

I commit the file, push to GitHub, then open the Actions tab (Source). If the run is green, CI is working. If it fails, the log tells me exactly which command failed. I fix it locally, commit again, and CI re-runs.

Here is the verification loop:

  1. Commit the workflow filegit add .github/workflows/ci.yml && git commit -m "Add CI"
  2. Push to GitHubgit push origin main
  3. Open the Actions tab — navigate to your repo on GitHub and click the "Actions" tab
  4. Check the run status — green check means all steps passed; red X means a step failed
  5. Read the failing step's log — expand the failed step to see the exact error output
  6. Fix locally, commit, push again — CI automatically re-runs on the new commit

Common failures I have hit

  • Workflow never runs: the default branch is not named main.
  • Install fails: package-lock.json is missing or out of sync.
  • Passes locally, fails in CI: local environment hides an assumption, like a different Node version or a missing env var.

Common failures and fixes

SymptomLikely causeFix
Workflow never runsDefault branch is not mainUpdate the branches filter or rename your branch (Source)
npm install failspackage-lock.json missing or out of syncRun npm install locally and commit the lock file (Source)
Passes locally, fails in CIDifferent Node version or missing env varPin node-version in setup-node and add required secrets (Source)
Lint passes locally, fails in CIDifferent ESLint config or plugin versionEnsure .eslintrc and all plugins are committed to the repo
Build fails with "command not found"package.json script is missingVerify lint and build scripts exist in package.json (Source)

The fix is always to make local and CI more similar, not to weaken the checks.

Debugging AWS-specific failures

Once the deploy step is in place, a new class of failures shows up:

  • Not authorized to perform: sts:AssumeRoleWithWebIdentity — the IAM role's trust policy does not include your repo. The trust policy must list repo:your-org/your-repo:ref:refs/heads/main in the StringLike condition. I once spent an hour on this because the condition had ref:refs/heads/master and the branch was main.
  • AccessDenied on s3 sync — the role lacks s3:PutObject and s3:DeleteObject on the bucket. Scope the policy to the bucket ARN, not *.
  • CloudFront invalidation hangs — invalidating /* on a large distribution can take 5-10 minutes. If the job times out, scope the invalidation to the paths that actually changed.

Cost considerations

GitHub gives 2,000 free Actions minutes per month on a personal account. A single workflow that runs lint + build takes about 2-3 minutes per run. At 10 pushes a day, that is roughly 900 minutes a month — well within the free tier. The deploy job adds another 2-3 minutes but only runs on pushes to main, so it is less frequent.

On the AWS side, S3 storage for a static site is negligible — a few cents a month for a typical blog. CloudFront costs more if traffic is high, but for a personal site it stays under $1. The real cost trap is forgetting to clean up old S3 object versions if you enable versioning; those accumulate silently.

Alternative approaches I considered

  • Netlify deploys instead of AWS. For static sites, Netlify is simpler — no IAM, no bucket, no invalidation. I use it for this blog. The AWS pipeline above is for projects where I need more control over the CDN config or where the client already has an AWS account.
  • GitHub Pages. Free and built-in, but no CDN configuration and no custom headers. Fine for docs, not for a production site.
  • Self-hosted runners. Only worth it if you are burning through the free minutes or need access to private network resources. For everything I do, the hosted runners are fine.
Cloud operations checkpoint illustration for this section.

Closing

A CI setup is not measured by complexity. It is measured by whether it runs my real build in a clean environment and tells me when something breaks. If I have a workflow file, a visible run, and a green check, CI is doing its job.

References