Deploy to AWS Amplify

The build is a folder of files, so deploying means getting dist/ onto a host. This site is hosted on AWS Amplify, and this guide covers the first upload that sets up the app, updating it by hand, and deploying automatically whenever main changes.

Upload

Build the zip

With the containers running, build the site and zip it:

Terminal
composer build:zip

You get dist/, and dist.zip beside it at the root of the repo. That zip is what Amplify deploys.

The build renders each page through the running nginx and PHP containers, so start them with docker compose up --build first, on your machine or in CI.

Create the app

To put the site on AWS Amplify Hosting:

  1. In the Amplify console, create a new app and choose to deploy without a Git provider. The branch name doesn't matter. I use main as that is my default branch.
  2. Upload dist.zip. That first upload creates the branch that later deploys update.
  3. Once it's live, add your domain in the app's custom domain settings. Amplify adds a rule that redirects the bare domain to www.

Serve the 404 page

Amplify needs one rule to serve dist/404.html. Its default /<*> rule sends missing pages to /index.html, so they show the homepage. Under Hosting > Rewrites and redirects, choose Manage redirects and point that rule at /404.html with the type 404. The rule only applies when no file matches, so the other pages are unaffected.

With your domain added, the rules look like this:

Rewrites and redirects
[
  {
    "source": "https://yoursite.com",
    "status": "302",
    "target": "https://www.yoursite.com",
    "condition": null
  },
  {
    "source": "/<*>",
    "status": "404",
    "target": "/404.html",
    "condition": null
  }
]

Update

Run composer build:zip, then in the Amplify console open the app, choose its branch, and drop the new dist.zip onto it to deploy an update. There's no pipeline and no credentials to set up, which makes it the quickest way to put a preview in front of a client.

Auto-deploy

This site deploys itself whenever main changes, including when a pull request is merged. A GitHub Actions workflow starts the php and nginx containers on the runner, runs composer build:zip, uploads the zip through Amplify's deployment API, and waits for the job, so a failed deploy fails the run. If you use this code, replace <region>, <account-id>, <app-id>, <branch>, <owner> and <repo> in the policies.

Deploying from GitHub Actions

# Builds the static site and deploys it to an AWS Amplify app that was set up to
# deploy without a Git provider.
name: Deploy

on:
  # Runs for every push to main, which includes merged pull requests
  push:
    branches: [main]
  workflow_dispatch:

# One deploy at a time. A newer push waits for the running one instead of racing it.
concurrency:
  group: deploy
  cancel-in-progress: false

permissions:
  contents: read
  # Lets configure-aws-credentials sign in to AWS with OIDC, so no AWS keys are stored
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    env:
      # Repository variables, under Settings > Secrets and variables > Actions
      AMPLIFY_APP_ID: ${{ vars.AMPLIFY_APP_ID }}
      AMPLIFY_BRANCH: ${{ vars.AMPLIFY_BRANCH }}
      # Layer docker-compose.ci.yml over the usual config, for every docker compose
      # command here and the one composer build runs
      COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml

    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.5"
          tools: composer

      # Installs the pnpm version named by packageManager in package.json
      - uses: pnpm/action-setup@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm

      - name: Install dependencies
        run: |
          composer install --no-interaction --no-progress
          pnpm install --frozen-lockfile

      - name: Start PHP and nginx
        run: |
          # The php container runs as this user, so the files it writes stay ours
          HOST_UID=$(id -u)
          HOST_GID=$(id -g)
          export HOST_UID HOST_GID
          echo "HOST_UID=$HOST_UID" >> "$GITHUB_ENV"
          echo "HOST_GID=$HOST_GID" >> "$GITHUB_ENV"

          # Create dist/ before Docker mounts it, or Docker creates it owned by root
          mkdir -p dist

          # The build only needs these two; pnpm build runs here, not in the vite container
          docker compose up -d --wait php nginx

      - name: Build
        run: composer build:zip

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Deploy to Amplify
        run: |
          # Amplify hands back a job and a URL to upload the zip to
          deployment=$(aws amplify create-deployment --app-id "$AMPLIFY_APP_ID" --branch-name "$AMPLIFY_BRANCH")
          job_id=$(jq -r .jobId <<< "$deployment")
          upload_url=$(jq -r .zipUploadUrl <<< "$deployment")

          curl --fail --silent --show-error -H "Content-Type: application/zip" --upload-file dist.zip "$upload_url"

          aws amplify start-deployment --app-id "$AMPLIFY_APP_ID" --branch-name "$AMPLIFY_BRANCH" --job-id "$job_id" > /dev/null
          echo "Started Amplify job $job_id"

          # Wait for the job, so a failed deploy fails this run
          while true; do
            status=$(aws amplify get-job --app-id "$AMPLIFY_APP_ID" --branch-name "$AMPLIFY_BRANCH" --job-id "$job_id" --query job.summary.status --output text)
            case "$status" in
              SUCCEED)
                echo "Deployed"
                break
                ;;
              FAILED | CANCELLED)
                echo "::error::Amplify job $job_id finished with status $status"
                exit 1
                ;;
            esac
            sleep 10
          done

Because the app deploys without a Git provider, there's no step where you authorize Amplify on GitHub and choose repositories. Amplify never reads your code, only the zip the workflow uploads. Instead, GitHub Actions signs in to AWS: the OIDC provider makes your AWS account accept GitHub's tokens, the trust policy's sub limits that to this repository's main branch, and the IAM policy limits the role to deploying to your one Amplify branch.

To set it up once:

  1. Create the app and upload one dist.zip by hand, as in Create the app. The workflow deploys to the branch that upload creates.
  2. Add GitHub as an identity provider in your AWS account. Each account needs this once, so skip it if token.actions.githubusercontent.com is already listed under Identity providers.
    1. Open the IAM console, choose Identity providers in the navigation pane, then Add provider.
    2. For Provider type, choose OpenID Connect.
    3. For Provider URL, enter https://token.actions.githubusercontent.com.
    4. For Audience, enter sts.amazonaws.com.
    5. Choose Add provider.
  3. Create an IAM role with the trust policy above, so the workflow signs in without any AWS keys stored in the repository, and give it the IAM policy above. The trust policy's sub has to match what GitHub sends, which you can check with gh api repos/<owner>/<repo>/actions/oidc/customization/sub. If it returns "use_immutable_subject": true, the sub carries numeric IDs too: repo:<owner>@<owner-id>/<repo>@<repo-id>:ref:refs/heads/main.
  4. Under Settings > Secrets and variables > Actions, add AWS_ROLE_ARN as a repository secret, and AWS_REGION, AMPLIFY_APP_ID and AMPLIFY_BRANCH as repository variables.
On a Linux runner, files the php container writes to dist/ would belong to root, and the formatting step after the build couldn't rewrite them. docker-compose.ci.yml runs php as the runner's user instead.

Other hosts

Any other static host works the same way: upload what's in dist/. To deploy on every push, run composer build in a CI pipeline and publish dist/. The workflow above is specific to Amplify, so Vercel, Cloudflare Pages and other hosts need their own deploy step, and their own way to serve 404.html for missing pages.