On this page

Troubleshooting and the production workflow

The failures that actually happen in CI: lock file errors, OIDC AssumeRoleWithWebIdentity errors, S3 access denied and region mismatches, plus how to add cache invalidation.

Troubleshooting: lock file errors

One of the first errors I encountered was:

Error: Dependencies lock file is not found

The relevant configuration was:

cache: npm

The solution was to make sure the repository contained:

package-lock.json

Then:

npm ci

worked.

If you encounter this problem:

npm install
git add package-lock.json
git commit -m "Add package lock"
git push

Then rerun the workflow.

Troubleshooting: OIDC AssumeRoleWithWebIdentity errors

Another important failure looked like:

Could not assume role with OIDC:
Not authorized to perform sts:AssumeRoleWithWebIdentity

This is different from an S3 permissions error.

It means GitHub successfully reached AWS, but AWS rejected the attempt to assume the IAM role.

The first things to check are:

The four values the OIDC trust policy depends on GitHub OIDC provider Provider URL Audience IAM trust policy OIDC subject claim
A mismatch in any one of them fails the AssumeRoleWithWebIdentity call, which is the usual cause of the error.

The IAM permissions policy is not the first thing to troubleshoot.

The trust relationship controls:

Who can assume this role?

The permissions policy controls:

What can the role do after it has been assumed?

Troubleshooting: S3 AccessDenied

If the deployment itself fails with S3 access errors, check:

IAM role permissions
S3 bucket policy
S3 bucket name
AWS region
CloudFront origin configuration

But if the homepage loads while internal links return:

AccessDenied

then don’t immediately assume the IAM policy is wrong.

It may instead be a URL-routing problem.

In this setup, the solution was a CloudFront Function that converts:

/projects

to:

/projects/index.html

Troubleshooting: AWS region

The AWS region must be consistent.

The GitHub Action uses:

aws-region: eu-north-1

The S3 bucket exists in a specific region.

CloudFront then references the S3 origin.

If the region is incorrect, deployment or origin access can fail.

So check the S3 bucket’s actual region, and make sure the aws-region value in the workflow matches it. A mismatch there is the most common cause of a successful AssumeRole step followed by an S3 access-denied error.

Optional improvement: automatic CloudFront invalidation

CloudFront caches content.

After uploading new files to S3, cached objects can remain available at CloudFront until they expire or are invalidated.

A deployment can therefore optionally perform a cache invalidation after the S3 sync.

The command is:

aws cloudfront create-invalidation \
  --distribution-id "$DISTRIBUTION_ID" \
  --paths "/*"

The workflow can therefore become:

- name: Invalidate CloudFront
  env:
    DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}
  run: |
    aws cloudfront create-invalidation \
      --distribution-id "$DISTRIBUTION_ID" \
      --paths "/*"

This deployment does not use that step today: the sync replaces the files, and a page already cached at the edge keeps serving the previous build until its TTL expires. Add the step when a stale page is worse than an extra API call.

If this is used, the IAM role needs permission for CloudFront invalidation.

For example:

{
  "Effect": "Allow",
  "Action": [
    "cloudfront:CreateInvalidation"
  ],
  "Resource": "arn:aws:cloudfront::YOUR_AWS_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID"
}

Astro’s AWS deployment documentation also demonstrates S3 synchronization followed by CloudFront invalidation. Astro AWS deployment documentation

Final production workflow

The workflow that runs on every push is:

name: Deploy Astro to S3

on:
  push:
    branches:
      - main

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v7

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci --no-audit --no-fund

      - name: Build Astro
        run: npm run build

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsDocumentationBlog
          aws-region: eu-north-1
          mask-aws-account-id: true

      - name: Deploy to S3
        env:
          AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
        run: |
          aws s3 sync ./dist "s3://${AWS_S3_BUCKET}" --delete --only-show-errors

This workflow contains no:

AWS access key
AWS secret access key
personal domain

The account number and the bucket name come from repository secrets, and the region is written directly into the workflow. See Configuring the workflow for why they are secrets and not variables.

The final architecture

The completed system can be summarized as:

Where a deployment failure can occur Astro blog GitHub git push GitHub Actions npm ci · npm run build AWS IAM/STS OIDC Amazon S3 static files CloudFront CDN / HTTPS · URL rewriting Website
Each box is a place the workflow can stop, which is why the log location matters as much as the error itself.

The developer experience

The infrastructure may look complicated when written out.

But from the developer’s perspective, it is extremely simple.

I write an article.

src/content/articles/new-article.md

Then:

git add .
git commit -m "Add new article"
git push origin main

And the deployment process takes over.

The full path from a push to a live page git push GitHub GitHub Actions Astro build OIDC authentication AWS IAM role S3 deployment CloudFront Website
This is the whole chain to check when a deployment fails; the failing step tells you where to look.

No FTP.

No manual upload.

No SSH.

No manually running the build on a server.

No permanent AWS credentials stored in GitHub.

Lessons learned

The most useful part of this project was not simply getting an Astro site running.

It was understanding how the individual components fit together.

GitHub

GitHub is the source of truth for the project.

GitHub Actions

GitHub Actions automates the build and deployment.

Astro

Astro converts the source project into static web files.

OIDC

OIDC establishes an identity relationship between GitHub and AWS without requiring long-lived AWS access keys.

IAM

IAM controls both:

Who can assume the deployment role?

and:

What can that role do?

S3

S3 stores the generated website.

CloudFront

CloudFront provides the public distribution layer, caching and HTTPS.

CloudFront Functions

CloudFront Functions can modify incoming URLs before they reach S3, which solves the clean URL problem for Astro’s generated index.html pages.

The most important security principle

The final architecture follows a simple principle:

Give the deployment system exactly the permissions it needs, and nothing more.

GitHub Actions does not receive administrator access to AWS.

It receives a specific role.

That role is restricted by:

Repository
    +
Branch
    +
OIDC identity
    +
Specific AWS permissions
    +
Specific S3 bucket

This is much better than giving a CI system broad AWS permissions.

Final result

What started as:

"I want an engineering blog."

became a complete CI/CD system:

The deployment chain end to end Astro │ Blog │ git push GitHub │ GitHub Actions │ npm ci │ npm run build │ OIDC AWS IAM/STS │ S3 │ Static files │ CloudFront │ CDN / HTTPS │ URL rewriting │ Website
Following the chain from a push to a served page is usually the fastest way to localise a failed deployment.
```

And publishing an article is now simply:

git push

That is the part I like most about the setup.

The infrastructure is doing its job in the background, while the development workflow stays simple.