AWS S3 + CloudFront — one-time setup
This page walks through provisioning the AWS resources that uniweb deploy --host=s3-cloudfront expects. You only do this once per site. After it's done, every deploy is uniweb deploy and that's it.
If you already have an S3 bucket + CloudFront distribution and just want the Uniweb-specific bits, jump to I already have AWS infrastructure.
If you're starting from scratch, follow the sections in order.
Related: Deployment overview covers all hosts (Vercel, Netlify, Cloudflare Pages, GitHub Pages, S3+CloudFront, etc.). This page is the deep-dive for AWS.
What you'll create
- An S3 bucket that holds the built site (
dist/). - A CloudFront distribution that fronts the bucket as the public origin.
- An Origin Access Control (OAC) that lets the distribution read from the bucket while keeping the bucket private to the rest of the internet.
- A CloudFront Function that resolves directory paths (
/about→/about/index.html). - An IAM user (or role) with the minimum permissions
uniweb deployneeds.
The IAM user explicitly cannot mutate distribution config or create functions — provisioning is a one-time setup; deploys can only push files and invalidate.
Prerequisites
Before you start:
- An AWS account.
- The
awsCLI installed locally and configured. macOS:brew install awscli. - A site that builds with
uniweb build(try it once before deploying).
Starting from scratch
1. Create an S3 bucket
In the AWS Console: S3 → Create bucket.
| Setting | Value |
|---|---|
| Bucket name | Globally unique — pick your own |
| AWS Region | Any region (see below) |
| Object Ownership | ACLs disabled (default) |
| Block Public Access | All four ON (the default) |
| Bucket Versioning | Optional |
| Default encryption | SSE-S3 (default) |
| Static website hosting | Do NOT enable (see below) |
Bucket name. Must be globally unique across all AWS accounts. We use uniweb-app-marketing as the example below — pick your own.
AWS Region. Any region works. Pick what's close to your users or matches the rest of your infra.
Block Public Access. Leave all four boxes ON — do not unblock anything. The bucket stays private; CloudFront reaches it via OAC.
Bucket Versioning. Enable if you want rollback-by-S3-version. Optional otherwise.
Static website hosting. Do not enable this. We use the bucket's REST endpoint via OAC, not the website endpoint. The two are mutually exclusive — the website endpoint requires a public bucket, which we don't want. See the note below.
Click Create bucket.
Why not enable static website hosting? The website endpoint requires the bucket to be public — anyone with the URL can bypass CloudFront's WAF, logging, and security headers. The REST endpoint via OAC keeps the bucket private and only reachable through your distribution.
2. Create the CloudFront Function
CloudFront Functions are global — no region.
In the AWS Console: CloudFront → Functions → Create function.
| Setting | Value |
|---|---|
| Name | uniweb-directory-index |
| Runtime | cloudfront-js-2.0 |
Click Next, paste the source from your build's dist/cloudfront-function.js:
function handler(event) {
var request = event.request
var uri = request.uri
if (uri.endsWith('/')) {
request.uri = uri + 'index.html'
} else if (!uri.split('/').pop().includes('.')) {
request.uri = uri + '/index.html'
}
return request
}Click Save changes, then Publish at the top of the function page (this promotes the development version to LIVE — required before you can attach it to a distribution).
What the function does. S3's REST endpoint doesn't auto-resolve directory indexes.
/aboutagainst S3 returns 403 because there's no object literally namedabout. The function rewrites/aboutto/about/index.htmlat the edge, before the cache lookup.
3. Create the CloudFront distribution
In the AWS Console: CloudFront → Distributions → Create distribution.
Distribution type
Pick Single website or app.
Not "Multi-tenant" — that's for SaaS providers serving many customer-owned domains from shared edge config. For a single site, even one that combines marketing + app behind path-based routing later, the single-distribution path is right.
Origin
| Setting | Value |
|---|---|
| Origin domain | Your bucket's REST endpoint (see below) |
| Origin path | leave empty |
| Origin access | Origin access control settings (recommended) |
| Origin access control | Click Create new OAC (see below) |
Origin domain. Pick the bucket from the dropdown (e.g. uniweb-app-marketing.s3.ca-central-1.amazonaws.com). Pick the REST endpoint, not the static-website endpoint.
Origin access control. Click Create new OAC. Name it uniweb-app-oac. Sign requests = "Yes, sign requests (recommended)". Origin type = S3. Click Create.
A blue alert appears: "You must update the S3 bucket policy." Leave it for now; you'll do this in step 4 once the distribution exists.
Default cache behavior
| Setting | Value |
|---|---|
| Path pattern | Default |
| Viewer protocol policy | Redirect HTTP to HTTPS |
| Allowed HTTP methods | GET, HEAD |
| Cache policy | CachingOptimized (AWS-managed) |
| Origin request policy | None |
| Compress objects automatically | Yes |
Function associations
| Setting | Value |
|---|---|
| Viewer request — Function type | CloudFront Functions |
| Viewer request — Function ARN/Name | uniweb-directory-index (the one you published in step 2) |
Leave all other association slots empty.
Settings
| Setting | Value |
|---|---|
| Price class | See below — pick what fits your traffic |
| Alternate domain name (CNAME) | Leave empty for now |
| Default root object | index.html |
Price class. "Use only North America and Europe" is the cheapest option. "All edge locations" gives global edge presence. Pick by where your users are.
Alternate domain name (CNAME). Leave empty for now — we're using the auto-generated *.cloudfront.net domain. Add a real domain later when you're ready (see Pointing your custom domain below).
Click Create distribution.
CloudFront takes 3–5 minutes to fully deploy. Status goes Deploying → Enabled. You can continue with steps 4–6 while it deploys.
4. Bucket policy — grant CloudFront read access
After the distribution is created, AWS shows a banner with a copyable bucket policy, or you can paste the one below.
Go to S3 → your bucket → Permissions → Bucket policy → Edit:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::YOUR-AWS-ACCOUNT-ID:distribution/YOUR-DISTRIBUTION-ID"
}
}
}
]
}Replace YOUR-BUCKET-NAME, YOUR-AWS-ACCOUNT-ID (12 digits, top-right of the console), and YOUR-DISTRIBUTION-ID (e.g. E1ABC...).
Click Save changes.
5. Custom error responses
In the distribution → Error pages tab → Create custom error response, do this twice:
| Field | 404 entry | 403 entry |
|---|---|---|
| HTTP error code | 404: Not Found | 403: Forbidden |
| Customize error response | Yes | Yes |
| Response page path | /404.html | /404.html |
| HTTP Response code | 404 | 404 |
| Error caching minimum TTL | 60 | 60 |
Why both 403 and 404? S3 returns 403 for missing-object-via-OAC (not 404). Map both to your branded
/404.htmlso genuinely-not-found paths return real 404 status with the marketing 404 page.Don't map 404 →
/index.htmlwith status 200. Pre-rendered Uniweb sites have real files at every known route; legitimate 404s should return real 404 status.
6. IAM user for uniweb deploy
Go to IAM → Users → Create user.
| Setting | Value |
|---|---|
| User name | e.g. uniweb-app-deploy |
In permissions: choose Attach policies directly → Create policy → JSON tab → paste:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "S3Sync",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:DeleteObject",
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::YOUR-BUCKET-NAME",
"arn:aws:s3:::YOUR-BUCKET-NAME/*"
]
},
{
"Sid": "CloudFrontInvalidate",
"Effect": "Allow",
"Action": ["cloudfront:CreateInvalidation", "cloudfront:GetDistribution"],
"Resource": "arn:aws:cloudfront::YOUR-AWS-ACCOUNT-ID:distribution/YOUR-DISTRIBUTION-ID"
}
]
}Replace the same three values as in step 4. Save the policy as uniweb-app-deploy-policy. Attach it to the user. Finish creating the user.
This policy grants only what uniweb deploy needs: push files, delete files, list the bucket, create invalidations, and read distribution metadata for preflight + post-deploy verification. It explicitly does NOT grant cloudfront:UpdateDistribution or cloudfront:CreateFunction — distribution config and function attachment are one-time provisioning steps you do via the console (or your IaC); deploys can never mutate them.
7. Create an access key for the IAM user
Open the user → Security credentials tab → Create access key.
- Use case: Command Line Interface (CLI).
- Description tag:
uniweb-app local deploy.
Click Create access key. Copy the Access key ID and Secret access key immediately — the secret is only shown once.
8. Configure the AWS CLI locally
Use a named profile so deploy creds don't collide with other AWS work:
aws configure --profile uniweb-app-deployPrompts:
AWS Access Key ID [None]: AKIA...
AWS Secret Access Key [None]: ...
Default region name [None]: <your-bucket-region>
Default output format [None]: jsonVerify:
aws --profile uniweb-app-deploy sts get-caller-identity
aws --profile uniweb-app-deploy s3 ls s3://YOUR-BUCKET-NAME/
aws --profile uniweb-app-deploy cloudfront get-distribution --id YOUR-DISTRIBUTION-ID --query 'Distribution.Status'You should see your IAM user's ARN, an empty bucket listing (not AccessDenied), and "Deployed" (not "InProgress").
You're done with AWS setup. Skip to Wire it into deploy.yml.
I already have AWS infrastructure
If you already have an S3 bucket + CloudFront distribution serving a static site, three things are specific to Uniweb:
1. Attach the directory-index Function
CloudFront → Functions → Create function (viewer-request, JS 2.0). Paste the source from your build's dist/cloudfront-function.js (or copy from step 2 above). Publish, then associate it with your distribution's default cache behavior as Viewer request.
2. Add 404 + 403 custom error responses
Both → /404.html with status 404. See step 5 above for details.
3. Confirm IAM permissions for the deploy user
The deploy user/role needs:
s3:PutObject,s3:DeleteObject,s3:GetObject,s3:ListBucketon the bucket.cloudfront:CreateInvalidation,cloudfront:GetDistributionon the distribution.
See step 6 above for the exact policy JSON.
Wire it into deploy.yml
Create deploy.yml next to your site.yml:
default: production
targets:
production:
host: s3-cloudfront
bucket: YOUR-BUCKET-NAME
distributionId: YOUR-DISTRIBUTION-ID
region: YOUR-BUCKET-REGION
profile: uniweb-app-deploy # optional — omit if using default credentialsReplace the four substitutions with your real values. Optional fields like cacheRules and invalidationPaths have sensible defaults; see Deployment reference for the full schema.
The deploys: block at the bottom of deploy.yml is auto-managed by uniweb deploy after each successful deploy. Don't edit it by hand.
First deploy
cd path/to/your-site
uniweb deployThe CLI runs build → preflight → S3 sync → CloudFront invalidation → writes deploys.<target> to deploy.yml. You should see the distribution domain printed in the success output. Hit it in a browser:
https://YOUR-DISTRIBUTION.cloudfront.net/
https://YOUR-DISTRIBUTION.cloudfront.net/about
https://YOUR-DISTRIBUTION.cloudfront.net/this-does-not-exist # should 404 with /404.htmlIf /about returns 404, the CloudFront Function isn't attached. Re-check step 2.
Pointing your custom domain
When you're ready to use a real domain (yoursite.com):
- Request an ACM certificate in us-east-1 for
yoursite.com(andwww.yoursite.comif needed). DNS-validated. - In the CloudFront distribution → General tab → Edit → add the domain to Alternate domain names (CNAMEs) and select the cert.
- Point your DNS to the distribution domain (
YOUR-DISTRIBUTION.cloudfront.net) via CNAME or alias record.
ACM cert region is the only US-East-1 dependency in this setup. The bucket can stay wherever you put it.
Troubleshooting
/about returns 404, but /about/index.html works. The CloudFront Function isn't attached. See step 2.
All paths return 403 Forbidden. The bucket policy isn't granting CloudFront's OAC read access, or the policy's AWS:SourceArn doesn't match the distribution. See step 4.
uniweb deploy fails with AccessDenied on s3 sync. The IAM user is missing s3:PutObject or s3:ListBucket on the bucket. See step 6.
uniweb deploy fails with NoSuchBucket. Bucket name in deploy.yml is wrong, or the region is wrong (S3 buckets are region-bound). See deploy.yml bucket and region.
Edge serves stale content after deploy. CloudFront invalidation is fire-and-forget — propagation takes 30s–2min. New visitors will see fresh content within that window. Hard-refresh your browser if you're testing.
404 page returns 200 status. A custom error response is mis-configured — check the HTTP Response code field on the error response (must be 404, not 200). See step 5.
See also
- Deployment overview — all supported hosts, when to pick which.
- Deploying with Uniweb — conceptual guide to the two-artifact model.
- CLI commands —
uniweb deployflags.