# AWS Production Deployment

> Canonical source: [AWS Production Deployment](https://docs.hellowes.com/aws/)

A recommended AWS setup for hosting a WCH project: a private S3 bucket receives the WCH archive, CodePipeline or Lambda extracts it, and CloudFront serves the site.

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_IA` storage 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-acl` condition to any policy; it will deny every upload.
- **Page layout inside the zip**: every page is a folder with an `index.html` inside, for example `about/index.html`. The 404 page is at `404/index.html`.

## 1. Create an S3 bucket

1. In the AWS Console, open S3 and choose Create bucket.
2. For the bucket name, use your site's domain, for example `www.example.com`. Matching the domain keeps later steps simple.
3. Leave Block all public access turned on.
4. 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`.

1. In IAM, open Roles and choose Create role.
2. 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.
3. On the permissions step, create and attach a policy with the four actions above on `arn:aws:s3:::YOUR-BUCKET-NAME/*`.
4. Name the role, for example `wch-s3-upload`, and create it.
5. 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](/aws-assume-role-for-s3-destinations) page.

### Option B: Resource Policy

1. In WCH, add the AWS destination and set Authentication Type to Resource Policy.
2. Expand AWS setup instructions. WCH shows its principal ARN and a ready-made bucket policy for your bucket name. Choose Copy JSON.
3. In S3, open your bucket, go to Permissions, then Bucket policy, and paste the JSON. Save.
4. 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 on `aws:SourceArn`, `aws:SourceVpc`, `aws:SourceVpce`, or `aws:PrincipalOrgID` will deny WCH, because a direct IAM user request carries none of them. Conditions on `aws:SecureTransport` and `s3:x-amz-storage-class` are fine.

### Option C: Access Key

1. In IAM, open Users and choose Create user. Name it, for example `wch-uploader`.
2. Choose Attach policies directly, then Create policy, and use the JSON below with your bucket name.
3. Finish creating the user, open it, go to Security credentials, and choose Create access key.
4. 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](/removing-dead-files-during-an-s3-publish).

### Option A: CodePipeline

CodePipeline watches for the WCH archive and extracts it into the bucket.

1. Open CodePipeline and choose Create pipeline. If asked for a type, choose Custom pipeline.
2. Name the pipeline.
3. 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`.
4. Skip the build and test stages.
5. For Deploy provider, choose S3 and pick the same bucket. Leave the deployment path empty and turn on Extract file before deploy.
6. 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](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html) and [ephemeral storage configuration](https://docs.aws.amazon.com/lambda/latest/dg/configuration-ephemeral-storage.html).

#### Create the function

1. In Lambda, create a function in the same AWS Region as the S3 bucket.
2. Select a supported Python runtime.
3. Start with a five-minute timeout and increase it if your archive needs more time, without exceeding Lambda's 15-minute maximum.
4. Configure enough ephemeral storage for the uploaded zip.
5. Attach the managed `AWSLambdaBasicExecutionRole` policy so the function can write logs to CloudWatch.
6. Add an inline policy that grants:
   - `s3:GetObject` on `arn:aws:s3:::YOUR-BUCKET-NAME/incoming/*`
   - `s3:PutObject` on `arn: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.

```python
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

1. In the S3 bucket, keep incoming WCH archives under a dedicated prefix. For example, set the WCH File Name to `incoming/project.zip`.
2. Add an S3 trigger to the Lambda function for Object Created events.
3. Set the trigger prefix to `incoming/` and the suffix to `.zip`.
4. Confirm that the trigger and function use the same AWS Region as the bucket.
5. Publish from WCH, then check the Lambda invocation and CloudWatch logs. Confirm that `index.html` and 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](https://docs.aws.amazon.com/lambda/latest/dg/with-s3.html).

> **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)

1. Open AWS Certificate Manager in the `us-east-1` region. CloudFront only accepts certificates from this region.
2. Choose Request certificate and add your domains, for example `example.com` and `www.example.com`.
3. Choose DNS validation and add the CNAME records ACM gives you at your DNS provider.
4. Wait for the certificate status to become Issued before continuing.

## 5. Create the CloudFront distribution

1. Open CloudFront and choose Create distribution.
2. 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.
3. For Origin access, choose Origin access control settings and create a new OAC. Let CloudFront update the bucket policy for you.
4. Set Default root object to `index.html`.
5. Under Alternate domain names, add your domain, for example `www.example.com`.
6. Under Custom SSL certificate, pick the ACM certificate from step 4.
7. Choose Create distribution, then copy the distribution domain name, for example `d123abc.cloudfront.net`.
8. 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.

1. In CloudFront, open Functions and choose Create function. Name it, for example `rewrite-folder-index`.
2. 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.

1. In your WCH project, open Extensions, then Deployments, and choose Add Deployment. Pick AWS S3.
2. 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.
3. Enter the Region of your bucket, for example `us-east-1`, and the Bucket Name with no `s3://` prefix.
4. Set File Name. This must match the object key watched by CodePipeline or Lambda. Use `project.zip` for CodePipeline, or a filtered key such as `incoming/project.zip` for Lambda. Tick Use project name to name the file after your project instead.
5. Turn on Is Production? for the production destination only. A project can have one production destination of each type.
6. 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.