AWS Production Deployment
Recommended architecture:
WCH → S3 bucket (private) → CodePipeline or Lambda unpacks the zip → CloudFront CDN → Visitors
This setup gives you:
- Private S3 storage with no public access
- CDN performance through CloudFront
- SSL through AWS Certificate Manager (ACM)
- A production workflow where the exact archive you tested is the one that goes live
What WCH sends to your bucket
Knowing this up front makes every later step easier to configure.
- One zip file per publish: WCH uploads a single archive to the object name you set in the destination's File Name field. The default is
project.zip. Nothing is unpacked by WCH; CodePipeline or Lambda handles that step below. - Multipart upload: every upload uses S3 multipart upload. That is why the permissions below include
s3:AbortMultipartUpload, so a failed upload can be cleaned up. - Storage class and encryption: objects are written with the
STANDARD_IAstorage class and SSE-S3 (AES256) server-side encryption. A bucket policy that requires SSE-KMS, or denies STANDARD_IA, will block the upload. - No ACL header: WCH does not send an ACL. Do not add an
s3:x-amz-aclcondition to any policy; it will deny every upload. - Page layout inside the zip: every page is a folder with an
index.htmlinside, for exampleabout/index.html. The 404 page is at404/index.html.
1. Create an S3 bucket
- In the AWS Console, open S3 and choose Create bucket.
- For the bucket name, use your site's domain, for example
www.example.com. Matching the domain keeps later steps simple. - Leave Block all public access turned on.
- Choose Create bucket.
Do not make the bucket public. CloudFront reads from the bucket through Origin Access Control (OAC), so the bucket stays private.
2. Give WCH permission to upload
WCH supports three ways to authenticate to your bucket. You pick one under Authentication Type when you add the destination in WCH.
- Assume Role (recommended): you create an IAM role that trusts the WCH identity. WCH requests one-hour temporary credentials for each publish. Nothing long-lived is stored in WCH.
- Resource Policy: you attach a bucket policy that names the WCH identity. No role and no keys. This is the quickest setup.
- Access Key: you create an IAM user and paste its access key and secret into WCH. Use this only if the other two are not an option for you.
Whichever you choose, WCH only needs these actions, and only on objects inside the bucket:
s3:PutObject
s3:PutObjectTagging
s3:AbortMultipartUpload
s3:ListMultipartUploadParts
WCH never reads, lists, or deletes objects, so the policy does not need s3:GetObject, s3:ListBucket, or s3:DeleteObject. The resource must end in /*. A bare bucket ARN matches nothing for these actions.
Option A: Assume Role (recommended)
The role must trust the WCH identity, not your own account. Ask WCH support for the exact principal ARN, or copy it from the AWS setup instructions panel in WCH (shown when Resource Policy is selected). The WCH AWS account ID is 985542660797.
- In IAM, open Roles and choose Create role.
- For Trusted entity type, choose Custom trust policy and paste the policy below. Replace the External ID with a long random value of your own, such as a UUID. You will enter the same value in WCH.
- On the permissions step, create and attach a policy with the four actions above on
arn:aws:s3:::YOUR-BUCKET-NAME/*. - Name the role, for example
wch-s3-upload, and create it. - Copy the role's ARN. You will paste it into WCH in step 6.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::985542660797:user/wes-assume-role"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "YOUR-EXTERNAL-ID"
}
}
}
]
}
The External ID is optional but strongly recommended. If you skip it, remove the whole Condition block. The full walkthrough, including troubleshooting, is on the AWS Assume Role for S3 Destinations page.
Option B: Resource Policy
- In WCH, add the AWS destination and set Authentication Type to Resource Policy.
- Expand AWS setup instructions. WCH shows its principal ARN and a ready-made bucket policy for your bucket name. Choose Copy JSON.
- In S3, open your bucket, go to Permissions, then Bucket policy, and paste the JSON. Save.
- Back in WCH, choose Test connection. WCH starts and aborts a multipart upload to confirm the policy works, without writing a file.
Policies that look right but deny WCH. If your organization uses Resource Control Policies, add an exception for AWS account
985542660797. Conditions onaws:SourceArn,aws:SourceVpc,aws:SourceVpce, oraws:PrincipalOrgIDwill deny WCH, because a direct IAM user request carries none of them. Conditions onaws:SecureTransportands3:x-amz-storage-classare fine.
Option C: Access Key
- In IAM, open Users and choose Create user. Name it, for example
wch-uploader. - Choose Attach policies directly, then Create policy, and use the JSON below with your bucket name.
- Finish creating the user, open it, go to Security credentials, and choose Create access key.
- Save the Access Key ID and Secret Access Key. The secret is shown only once. You will paste both into WCH in step 6.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowWCHUpload",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:PutObjectTagging",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*"
}
]
}
3. Choose CodePipeline or a Lambda function
WCH uploads one zip archive. Choose one of these AWS options to unpack it into the same bucket for CloudFront:
- CodePipeline gives you a visible, managed deployment workflow and is the better default when archives may be large or deploys need an audit trail.
- Lambda function uses fewer AWS resources and is a good fit for smaller sites when the archive can be processed within Lambda's runtime and temporary-storage limits.
Both options overwrite files included in the new archive, but neither removes old files that are no longer present. If stale files matter to you, see Removing Dead Files During an S3 Publish.
Option A: CodePipeline
CodePipeline watches for the WCH archive and extracts it into the bucket.
- Open CodePipeline and choose Create pipeline. If asked for a type, choose Custom pipeline.
- Name the pipeline.
- For Source provider, choose S3. Pick the bucket from step 1. For the object key, enter the File Name you will set in WCH, by default
project.zip. - Skip the build and test stages.
- For Deploy provider, choose S3 and pick the same bucket. Leave the deployment path empty and turn on Extract file before deploy.
- Choose Create pipeline.
Option B: Lambda function
Use this option when you want a lightweight, event-driven deployment. Lambda functions can run for up to 15 minutes, and their /tmp storage can be configured from 512 MB to 10,240 MB. Make sure the compressed archive fits comfortably within the storage you configure. See Lambda quotas and ephemeral storage configuration.
Create the function
- In Lambda, create a function in the same AWS Region as the S3 bucket.
- Select a supported Python runtime.
- Start with a five-minute timeout and increase it if your archive needs more time, without exceeding Lambda's 15-minute maximum.
- Configure enough ephemeral storage for the uploaded zip.
- Attach the managed
AWSLambdaBasicExecutionRolepolicy so the function can write logs to CloudWatch. - Add an inline policy that grants:
s3:GetObjectonarn:aws:s3:::YOUR-BUCKET-NAME/incoming/*s3:PutObjectonarn:aws:s3:::YOUR-BUCKET-NAME/*
If your bucket uses a customer-managed KMS key, the function role also needs the appropriate KMS permissions.
Add the function code
This example downloads the archive to /tmp, rejects unsafe ZIP paths, and streams each extracted file back to the bucket root with a detected content type.
import mimetypes
import os
import zipfile
from pathlib import PurePosixPath
from urllib.parse import unquote_plus
import boto3
s3 = boto3.client("s3")
ARCHIVE_PREFIX = "incoming/"
def lambda_handler(event, context):
for record in event.get("Records", []):
bucket = record["s3"]["bucket"]["name"]
key = unquote_plus(record["s3"]["object"]["key"])
if not key.startswith(ARCHIVE_PREFIX) or not key.endswith(".zip"):
continue
archive_path = f"/tmp/{os.path.basename(key)}"
s3.download_file(bucket, key, archive_path)
with zipfile.ZipFile(archive_path) as archive:
for member in archive.infolist():
if member.is_dir():
continue
destination = PurePosixPath(member.filename)
if destination.is_absolute() or ".." in destination.parts:
raise ValueError(f"Unsafe archive path: {member.filename}")
object_key = str(destination)
content_type = (
mimetypes.guess_type(object_key)[0]
or "application/octet-stream"
)
with archive.open(member) as file_body:
s3.upload_fileobj(
file_body,
bucket,
object_key,
ExtraArgs={"ContentType": content_type},
)
return {"statusCode": 200}
Add the S3 trigger
- In the S3 bucket, keep incoming WCH archives under a dedicated prefix. For example, set the WCH File Name to
incoming/project.zip. - Add an S3 trigger to the Lambda function for Object Created events.
- Set the trigger prefix to
incoming/and the suffix to.zip. - Confirm that the trigger and function use the same AWS Region as the bucket.
- Publish from WCH, then check the Lambda invocation and CloudWatch logs. Confirm that
index.htmland the page folders appear at the bucket root.
Prevent recursive invocations. The function writes extracted files back to the same bucket. Keep the trigger limited to the
incoming/prefix and write the site files outside that prefix, or every extracted file could invoke the function again. AWS documents this same-bucket recursion risk in its S3 trigger guidance.
CloudFront caching applies to both options. If you need updates to appear immediately, add a CloudFront invalidation after extraction. The role that creates it needs
cloudfront:CreateInvalidation.
4. Request an SSL certificate (ACM)
- Open AWS Certificate Manager in the
us-east-1region. CloudFront only accepts certificates from this region. - Choose Request certificate and add your domains, for example
example.comandwww.example.com. - Choose DNS validation and add the CNAME records ACM gives you at your DNS provider.
- Wait for the certificate status to become Issued before continuing.
5. Create the CloudFront distribution
- Open CloudFront and choose Create distribution.
- For Origin domain, select your S3 bucket. Use the standard bucket origin, for example
www.example.com.s3.amazonaws.com, not the S3 website endpoint. - For Origin access, choose Origin access control settings and create a new OAC. Let CloudFront update the bucket policy for you.
- Set Default root object to
index.html. - Under Alternate domain names, add your domain, for example
www.example.com. - Under Custom SSL certificate, pick the ACM certificate from step 4.
- Choose Create distribution, then copy the distribution domain name, for example
d123abc.cloudfront.net. - At your DNS provider, point your domain at the CloudFront domain name.
Serve folder index pages
Default root object only applies to the site root. WCH stores every other page as a folder with an index.html inside, and a standard S3 origin does not look inside folders. Without the function below, /about/ returns an error even though /about/index.html exists.
- In CloudFront, open Functions and choose Create function. Name it, for example
rewrite-folder-index. - Paste the code below, publish it, and associate it with your distribution's default behavior as a Viewer request function.
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
} else if (!uri.includes('.')) {
request.uri = uri + '/index.html';
}
return request;
}
Error pages
Under Error pages, add two custom error responses so visitors see your site's 404 page. A private bucket returns 403 for missing objects, so map both codes.
- 403: response page
/404/index.html, response code 404 - 404: response page
/404/index.html, response code 404
Caching
- Use the standard CloudFront caching policy for production.
- Setting every TTL to 0 makes changes show up immediately, which is handy while testing, but it removes most of the CDN benefit. Use a longer TTL once the site is live and invalidate the cache after each publish.
6. Add the destinations in WCH
Add two AWS S3 destinations: one for pre-production and one for production. Both can point at different buckets or the same bucket with different file names.
- In your WCH project, open Extensions, then Deployments, and choose Add Deployment. Pick AWS S3.
- Choose an Authentication Type and fill in its fields: Access Key ID and Secret Access Key, or Role ARN and External ID (Optional), or nothing extra for Resource Policy.
- Enter the Region of your bucket, for example
us-east-1, and the Bucket Name with nos3://prefix. - Set File Name. This must match the object key watched by CodePipeline or Lambda. Use
project.zipfor CodePipeline, or a filtered key such asincoming/project.zipfor Lambda. Tick Use project name to name the file after your project instead. - Turn on Is Production? for the production destination only. A project can have one production destination of each type.
- Save. For Resource Policy, choose Test connection first.
On the project Dashboard, the pre-production destination gets a Publish button, which builds a fresh archive and sends it. The production destination gets a Promote button, which sends the most recent archive you already built, byte for byte, without rebuilding. Test on pre-production, then promote.
7. Optional improvements
Performance
- Turn on compression, HTTP/2, and HTTP/3 in the distribution settings.
Security headers
Add a response headers policy in CloudFront for headers such as HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Content-Security-Policy.
Logging and monitoring
- CloudFront standard logs and S3 access logs
- CloudWatch alarms for elevated error rates
- AWS WAF if you need request filtering
This page is a baseline. Your DNS, security, compliance, and operational setup remain the responsibility of you or your infrastructure team.