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:
composer build:zip
You get dist/, and dist.zip beside
it at the root of the repo. That zip is what Amplify deploys.
docker compose up --build first, on your
machine or in CI.
Create the app
To put the site on AWS Amplify Hosting:
-
In the Amplify console, create a new app and choose to
deploy without a Git provider. The branch name doesn't
matter. I use
mainas that is my default branch. -
Upload
dist.zip. That first upload creates the branch that later deploys update. -
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:
[
{
"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
# Layered over docker-compose.yml by the deploy workflow.
#
# On Linux, files a container writes to a bind mount belong to the user it runs
# as. The build writes dist/ from the php container, then oxfmt rewrites those
# files on the host, so php runs as the CI user rather than root.
services:
php:
user: "${HOST_UID}:${HOST_GID}"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"amplify:CreateDeployment",
"amplify:StartDeployment",
"amplify:GetJob"
],
"Resource": [
"arn:aws:amplify:<region>:<account-id>:apps/<app-id>/branches/<branch>",
"arn:aws:amplify:<region>:<account-id>:apps/<app-id>/branches/<branch>/*"
]
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<account-id>: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:<owner>/<repo>:ref:refs/heads/main"
}
}
}
]
}
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:
-
Create the app and upload one
dist.zipby hand, as in Create the app. The workflow deploys to the branch that upload creates. -
Add GitHub as an identity provider in your AWS account. Each
account needs this once, so skip it if
token.actions.githubusercontent.comis already listed under Identity providers.- Open the IAM console, choose Identity providers in the navigation pane, then Add provider.
- For Provider type, choose OpenID Connect.
-
For Provider URL, enter
https://token.actions.githubusercontent.com. -
For Audience, enter
sts.amazonaws.com. - Choose Add provider.
-
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
subhas to match what GitHub sends, which you can check withgh api repos/<owner>/<repo>/actions/oidc/customization/sub. If it returns"use_immutable_subject": true, thesubcarries numeric IDs too:repo:<owner>@<owner-id>/<repo>@<repo-id>:ref:refs/heads/main. -
Under Settings > Secrets and variables > Actions, add
AWS_ROLE_ARNas a repository secret, andAWS_REGION,AMPLIFY_APP_IDandAMPLIFY_BRANCHas repository variables.
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.