Knowledge base · Guide
Rebuild a static site on S3 when content is released
- Kind
- Guide
- Published
- 12 Jun 2026
- Reading
- 12 min
Applies to: static React sites (Next.js static export, Vite, or similar) hosted on Amazon S3 behind CloudFront, built with GitHub Actions
You will learn: how to rebuild and publish your site automatically whenever a release is promoted to prod, using a Coho webhook, a small relay function, and a GitHub Actions workflow
The short version
A static site holds a copy of your content, made when it was built. When you promote a new release to prod, the site has to be built again to show it. This guide wires that up so nobody has to remember:
coho promote prod v1.4.0
│
▼
Coho ── environment.promoted ──▶ relay ── repository_dispatch ──▶ GitHub Actions
(signed webhook) (Lambda function URL) │ build from prod
▼
S3 ──▶ CloudFront
- Coho sends a signed
environment.promotedevent whenprodstarts serving a new release, and when it is rolled back. - A relay checks the signature and asks GitHub to start your deploy workflow.
- The workflow builds the site from
prod, uploads it to S3 in a safe order, and clears CloudFront's cache.
You need about half an hour, an AWS account with your bucket and distribution in it, and owner access to the Coho project.
Why a relay?
GitHub starts a workflow from outside through its repository_dispatch API, which expects a body of its own shape (event_type and client_payload). Coho sends every webhook in one standard, signed shape, so it cannot call that API directly. The relay is about thirty lines: it checks that the request really came from Coho, then makes the call GitHub expects. It is also the right place to decide which events deserve a build.
Step 1: build the site from prod
Your build should read content from the environment, not from a particular tag. Coho fires environment.promoted only once delivery is serving the new release, so a build started by the event reads exactly what was promoted. And a build you run by hand, for a code change, reads the same thing.
Make a delivery key for the build, limited to prod:
coho key create --label site-build --ref prod
The key is shown once. Save it in your GitHub repository as a secret named COHO_DELIVERY_KEY (Settings → Secrets and variables → Actions).
For Next.js, set the site to export static files:
// next.config.ts
const nextConfig = {
output: "export", // writes the site to out/
trailingSlash: true, // /about/ becomes /about/index.html, which S3 can serve
images: { unoptimized: true },
};
export default nextConfig;
With Vite or another bundler, the output folder is usually dist/, and its fingerprinted files are under assets/. Swap those names into the workflow in step 3.
Step 2: let GitHub Actions publish to AWS
The workflow signs in to AWS with OpenID Connect, so no AWS keys are stored in GitHub. You need three things in AWS.
The OIDC provider, once per AWS account: in IAM, add an identity provider for https://token.actions.githubusercontent.com with the audience sts.amazonaws.com.
A role the workflow can assume. Its trust policy names your repository and your default branch, because a repository_dispatch run always runs on the default branch:
{
"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:acme/marketing-site:ref:refs/heads/main"
}
}
}]
}
Permission to publish, and nothing more:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::acme-site" },
{ "Effect": "Allow", "Action": ["s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::acme-site/*" },
{
"Effect": "Allow",
"Action": ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"],
"Resource": "arn:aws:cloudfront::123456789012:distribution/E1ABCDEF2GHIJK"
}
]
}
Save the role's ARN, the bucket name, and the distribution id as repository variables (not secrets): AWS_ROLE_ARN, SITE_BUCKET, and SITE_DISTRIBUTION_ID. None of them is a credential. Only a run of this repository on main can assume the role.
One CloudFront detail. A private bucket served through CloudFront's origin access control has no directory index, so /about/ would not find /about/index.html. Add a viewer-request CloudFront Function to the distribution's default behavior:
function handler(event) {
var request = event.request;
if (request.uri.endsWith("/")) request.uri += "index.html";
else if (!request.uri.split("/").pop().includes(".")) request.uri += "/index.html";
return request;
}
Step 3: the deploy workflow
Save this as .github/workflows/deploy.yml. It runs when the relay asks for it, and by hand from the Actions tab.
name: deploy
run-name: Publish ${{ github.event.client_payload.target || 'by hand' }}
on:
repository_dispatch:
types: [coho-promoted]
workflow_dispatch:
# One publish at a time. A promotion that arrives mid-build waits, and several that
# arrive together collapse into one more build, which reads the newest release.
concurrency:
group: deploy
cancel-in-progress: false
permissions:
id-token: write # to sign in to AWS with OIDC
contents: read
env:
COHO_PROJECT: 0192… # your project's id: not a secret
COHO_REF: prod
COHO_LOCALE: en-US
COHO_DELIVERY_URL: https://delivery.coho-cms.com
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Build
env:
COHO_DELIVERY_KEY: ${{ secrets.COHO_DELIVERY_KEY }}
run: |
npm ci
npm run build
test -f out/index.html # stop here rather than publish an empty site
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Publish
env:
BUCKET: ${{ vars.SITE_BUCKET }}
DISTRIBUTION: ${{ vars.SITE_DISTRIBUTION_ID }}
run: |
set -euo pipefail
# 1. Fingerprinted files first, cached for a year. Nothing is deleted yet,
# so pages already open in a browser keep working.
aws s3 sync out/ "s3://$BUCKET/" \
--exclude '*' --include '_next/static/*' \
--cache-control 'public, max-age=31536000, immutable'
# 2. Then the HTML and everything else, which name the files above.
aws s3 sync out/ "s3://$BUCKET/" \
--exclude '_next/static/*' \
--cache-control 'public, max-age=0, must-revalidate'
# 3. Only now remove what this build no longer produces.
aws s3 sync out/ "s3://$BUCKET/" --delete
# 4. Clear CloudFront's copy, and wait for it.
id=$(aws cloudfront create-invalidation --distribution-id "$DISTRIBUTION" \
--paths '/*' --query 'Invalidation.Id' --output text)
aws cloudfront wait invalidation-completed --distribution-id "$DISTRIBUTION" --id "$id"
Why three syncs, not one. A single aws s3 sync --delete uploads files in no particular order. For a moment, new HTML can name scripts that are not uploaded yet, or old scripts can disappear from under a page a visitor already has open. Uploading the fingerprinted files first, the HTML second, and deleting last means every page in the bucket can find its files at every moment.
Why /*. Invalidating everything is simpler than working out which paths changed, and CloudFront's first thousand invalidation paths each month are free. A wildcard counts as one path.
Run the workflow once by hand before going on. If the site publishes, the AWS side is done.
Step 4: a token that can start the workflow
The relay needs a GitHub token allowed to send repository_dispatch to this one repository. Create a fine-grained personal access token (Settings → Developer settings → Fine-grained tokens):
- Repository access: only the site's repository.
- Permissions: Contents, read and write. That is what
repository_dispatchrequires; it needs nothing else. - Expiration: as your policy allows. Put a reminder in the calendar to replace it.
For an organization, a GitHub App installed on the repository does the same job without a token tied to one person.
Step 5: the relay
The relay is a Lambda function with a function URL: a public HTTPS address with no API Gateway to set up. Coho's signature is its authentication, so the URL needs no AWS auth of its own.
Make a folder for it, outside the site's code:
mkdir coho-relay && cd coho-relay
npm init -y
npm install @coho-cms/delivery
npm install --save-dev esbuild
// index.ts
import { CohoWebhookError, verifyWebhook } from "@coho-cms/delivery/webhooks";
type FunctionUrlEvent = { body?: string; isBase64Encoded: boolean; headers: Record<string, string> };
export const handler = async (request: FunctionUrlEvent) => {
// Verify the body exactly as it arrived: parsed and serialized again, it no longer matches the signature.
const raw = request.isBase64Encoded ? Buffer.from(request.body ?? "", "base64") : request.body ?? "";
let event;
try {
event = verifyWebhook(raw, request.headers, process.env.COHO_WEBHOOK_SECRET!);
} catch (error) {
if (error instanceof CohoWebhookError) return { statusCode: 400, body: error.reason };
throw error;
}
// A test event, or anything else this endpoint was not meant for: accepted, and nothing built.
if (event.type !== "environment.promoted") return { statusCode: 204 };
const target = event.data.target as { name?: string } | undefined;
const github = await fetch(`https://api.github.com/repos/${process.env.GITHUB_REPOSITORY}/dispatches`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
event_type: "coho-promoted",
client_payload: {
event: event.id,
environment: event.data.environment,
target: target?.name,
rollback: event.data.rollback,
},
}),
});
// Anything but success tells Coho to try again later.
if (!github.ok) return { statusCode: 502, body: `GitHub answered ${github.status}` };
return { statusCode: 204 };
};
Bundle it into one file, and create the function. It needs an execution role; the AWS managed policy AWSLambdaBasicExecutionRole, for its logs, is all it uses.
npx esbuild index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.mjs
(cd dist && zip -q ../relay.zip index.mjs)
aws lambda create-function --function-name coho-relay \
--runtime nodejs22.x --handler index.handler --zip-file fileb://relay.zip \
--role arn:aws:iam::123456789012:role/coho-relay \
--environment 'Variables={GITHUB_REPOSITORY=acme/marketing-site,GITHUB_TOKEN=github_pat_…,COHO_WEBHOOK_SECRET=whsec_placeholder}'
aws lambda create-function-url-config --function-name coho-relay --auth-type NONE
aws lambda add-permission --function-name coho-relay --statement-id public-url \
--action lambda:InvokeFunctionUrl --principal '*' --function-url-auth-type NONE
The second command prints the function's URL, such as https://abc123.lambda-url.us-east-1.on.aws/. The secret is a placeholder for now: Coho creates the real one in the next step.
Lambda encrypts its environment variables at rest. If your team keeps secrets in AWS Secrets Manager instead, read the two values from there when the function starts.
Step 6: the webhook
Create the endpoint in Coho, for promotions of prod only:
coho webhook create \
--url https://abc123.lambda-url.us-east-1.on.aws/ \
--event environment.promoted \
--ref prod \
--description "Rebuild the site on GitHub"
Coho answers with the endpoint's id and its signing secret, whsec_…, shown once. Put it into the relay:
aws lambda update-function-configuration --function-name coho-relay \
--environment 'Variables={GITHUB_REPOSITORY=acme/marketing-site,GITHUB_TOKEN=github_pat_…,COHO_WEBHOOK_SECRET=whsec_…}'
You can do the same from the project's Webhooks page in the Coho portal.
Step 7: test it, end to end
First, the signature. Send a test event:
coho webhook test <webhook-id>
Coho sends a ping. The relay checks its signature and answers 204 without starting a build. An answer of 400 signature means the relay has a different secret from the one Coho showed.
Then, the whole path. Promote a release:
coho promote prod v1.4.0 -m "Spring catalogue"
Within a few seconds of prod serving it, a run called Publish v1.4.0 appears in your repository's Actions tab. When it finishes, the site shows the new release.
A rollback publishes too. coho rollback prod is a promotion back to the previous release, so it sends the same event, and the site is rebuilt from the release prod went back to.
When something goes wrong
Coho keeps a log of every attempt, with the request it sent and the start of the response:
coho webhook deliveries <webhook-id>
coho webhook redeliver <webhook-id> <delivery-id>
| What you see | What it means |
|---|---|
The function URL answered 403 Forbidden | The function's resource policy does not let the public call it. Check its permissions in the Lambda console against AWS's current guide to function URLs |
The relay answered 400 signature | The relay's COHO_WEBHOOK_SECRET is not this endpoint's secret |
The relay answered 400 timestamp | The request was more than five minutes old when it arrived; redeliver it |
The relay answered 502 GitHub answered 404 | The token cannot see the repository, or GITHUB_REPOSITORY is misspelled |
The relay answered 502 GitHub answered 401 | The token has expired or been revoked |
No run appears, though the relay answered 204 | The workflow is not on the default branch, or its types does not include coho-promoted |
| The run fails at "Configure AWS credentials" | The role's trust policy does not name this repository and refs/heads/main |
Failures are retried for you. Coho retries a delivery that fails for about a day, so a GitHub or AWS outage costs a late build, not a missed one. An endpoint that fails for five days in a row is turned off, and the project's owners are told by email.
A build can occasionally run twice. Webhooks are delivered at least once, so the same event can arrive twice. Because the workflow always builds from prod, a second run publishes the same site again and does no harm.
Common questions
Can I rebuild a preview site when someone saves to a branch?
Yes, with the same relay. Subscribe a second endpoint to entries.changed on that branch, and have the preview build read the branch with a project token. Saves can come quickly, so let the workflow's concurrency group collapse them into one build.
Why not let GitHub build on a timer instead? A schedule rebuilds whether anything changed or not, and still leaves your site behind until the next run. The webhook builds once per promotion, seconds after it.
Does the webhook carry my content? No. Events carry names and ids only: which environment, which release, who did it. The build reads the content through delivery, with its own key.
Do I need the relay if my site is not static?
No. A site that renders on a server can receive the webhook itself and clear its own cache, with the same verifyWebhook check.