Self-Hosting Static Sites on S3, Nginx and Caddy
Managed static hosts — Cloudflare Pages, Netlify, Vercel, GitHub Pages — make publishing a folder of HTML almost effortless, and for most sites they are the right answer. But plenty of teams host static sites themselves: a regulated organisation that must keep data in its own cloud account, a company with a large existing AWS contract, a documentation portal behind corporate single sign-on, a site at a traffic level where per-request pricing matters, or simply a team that wants every header, log line and routing rule under its own control.
Self-hosting a static site is not hard, but it means rebuilding the conveniences a managed host provides: clean URLs, redirects, headers, TLS, cache rules, atomic deploys and rollback. This topic covers the three most common setups — S3 with CloudFront, nginx on a VM and Caddy on a VM — and how to rebuild each convenience. It sits inside Production-Ready Deployment & CI/CD Workflows, next to the managed-host comparison in Netlify vs Vercel Deployment Strategies.
What a Managed Host Was Doing for You
Before choosing a stack, list the features you will need to rebuild. Every managed static host provides roughly the same set:
| Feature | Managed host | S3 + CloudFront | nginx | Caddy |
|---|---|---|---|---|
| TLS certificates | automatic | ACM, automatic | certbot, cron | automatic |
Clean URLs (/guide/ → /guide/index.html) | automatic | CloudFront Function | try_files | try_files / file_server |
| Redirect rules | _redirects file | CloudFront Function or S3 rules | return 301 / map | redir |
| Custom headers | _headers file | response headers policy | add_header | header |
| Compression | automatic | CloudFront setting | gzip, brotli module | encode zstd gzip |
| Atomic deploy | built in | upload order or origin path switch | symlink swap | symlink swap |
| Instant rollback | one click | re-point or re-sync | symlink back | symlink back |
| Preview deploys | per PR | prefix per PR | subdomain per PR | subdomain per PR |
| Global edge | built in | built in | add a CDN | add a CDN |
The clean-URL row trips up more S3 deployments than any other. S3's REST endpoint does not resolve /guide/ to /guide/index.html; without a rewrite, every directory-style URL returns an XML error. Clean URLs and Trailing Slashes on S3 covers the fix.
S3 and CloudFront
For AWS-centric organisations this is the default. The bucket stays private; CloudFront reads it through Origin Access Control, serves from over 400 edge locations, terminates TLS with a free ACM certificate, and runs small CloudFront Functions on each request for URL rewriting and redirects.
aws s3 sync dist/ s3://docs-example-com/ --delete \
--exclude '*.html' --cache-control 'public, max-age=31536000, immutable'
aws s3 sync dist/ s3://docs-example-com/ --delete \
--exclude '*' --include '*.html' --cache-control 'public, max-age=0, must-revalidate'
aws cloudfront create-invalidation --distribution-id E2ABC123 --paths '/*'
Two uploads with different cache headers, assets first and HTML last, keep pages consistent during the deploy: new HTML never references an asset that has not been uploaded yet. The full setup is in Deploying a Static Site to S3 and CloudFront, and redirect handling in CloudFront Functions for Redirects.
Nginx on a VM
Nginx is fast, universally understood and gives complete control. A static site needs very little of it:
server {
listen 443 ssl http2;
server_name docs.example.com;
root /srv/docs/current;
include /etc/nginx/snippets/security-headers.conf;
location / {
try_files $uri $uri/index.html $uri.html =404;
}
location /_astro/ {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
}
error_page 404 /404.html;
}
The include inside the location block is not redundant: nginx's add_header directives in a nested block replace those from the parent rather than adding to them, so a location that sets Cache-Control loses every security header unless they are repeated. It is the most common self-hosting mistake, and it is invisible until someone checks the headers on an asset. The complete configuration is in Serving a Static Site with Nginx.
Caddy on a VM
Caddy does less configuration for the same result. It obtains and renews TLS certificates automatically, redirects HTTP to HTTPS, and enables modern protocols by default:
docs.example.com {
root * /srv/docs/current
encode zstd gzip
try_files {path} {path}/index.html {path}.html
file_server
header /_astro/* Cache-Control "public, max-age=31536000, immutable"
import security_headers
handle_errors {
rewrite * /404.html
file_server
}
}
For a single team running a handful of static sites on one VM, Caddy is the lowest-maintenance option available. See Serving a Static Site with Caddy.
Atomic Deploys and Rollback Without a Managed Host
A managed host swaps the whole site at once and keeps previous deploys for instant rollback. On a VM, the equivalent is a release directory per deploy and a symlink that points to the current one:
/srv/docs/
├── releases/
│ ├── 20260915-1412-a1b2c3d/
│ ├── 20260917-0931-e4f5a6b/
│ └── 20260918-1106-c7d8e9f/
└── current -> releases/20260918-1106-c7d8e9f
CI uploads a new release directory with rsync, then replaces the symlink with ln -sfn followed by an atomic mv -T. Every request sees either the whole old site or the whole new one. Rolling back is re-pointing the symlink at the previous directory — under a second. Zero-Downtime Deploys with Symlink Swaps has the script and the retention policy. On S3, the equivalent is uploading to a versioned prefix (/releases/c7d8e9f/) and changing the CloudFront origin path, or relying on upload ordering as shown above.
Caching in Front of a VM
A single VM in one region serves readers on other continents slowly: from a Frankfurt VM, time to first byte measured 38 ms from Berlin, 160 ms from New York and 310 ms from Sydney. A CDN in front removes most of that. Cloudflare's free plan or CloudFront with the VM as a custom origin both work. The cache policy is the same one used for managed hosts — long immutable lifetimes for hashed assets, short or revalidating lifetimes for HTML — described in CDN Caching Rules for SSGs. With the CDN in place, the VM mostly serves cache revalidations and can be very small.
Choosing Between the Three
The table of features is the same for all three stacks, so the choice comes down to who operates it and at what scale.
Choose S3 and CloudFront when the organisation already runs on AWS, when traffic is high or spiky, or when compliance requires the content to stay inside a specific cloud account with IAM-controlled access and CloudTrail logging. There is no server to patch, capacity is effectively unlimited, and everything can be described in Terraform. The costs are setup complexity — URL rewriting and redirects need a CloudFront Function — and slightly slower deploys because of cache invalidation.
Choose nginx when you already operate nginx elsewhere, need fine-grained control over request handling (complex redirect maps, authentication subrequests, logging formats), or are putting the static site next to other services on the same host. Its configuration language is widely known, which matters when the person on call at 2 a.m. is not the person who wrote it.
Choose Caddy for the smallest operational footprint on a VM: one binary, automatic HTTPS with renewal, sensible defaults, and a config file a newcomer can read in a minute. It is the natural choice for internal documentation portals, staging mirrors and small teams without dedicated infrastructure staff.
Whatever the choice, keep the build output identical across hosts. A site that builds the same dist/ folder for Cloudflare Pages, S3 and a VM can move between them in an afternoon, and a disaster-recovery plan can be as simple as a second deploy target.
Security on Self-Hosted Stacks
A managed host hardens the server layer for you; self-hosting hands that job back. For S3, the essentials are a bucket with Block Public Access enabled, Origin Access Control so only CloudFront can read it, and a deploy role limited to that one bucket and distribution. For a VM, they are key-only SSH, a firewall allowing only ports 22 (ideally restricted by source address), 80 and 443, unattended security updates, and a deploy user that can write to the releases directory and nothing else. On all three stacks, the security header set from Security Headers and Hardening for Static Sites applies unchanged — only the syntax differs. Directory listing must be off (it is by default in nginx and Caddy), and dotfiles such as a stray .env or .git folder copied into the build output must be denied explicitly, because the server will otherwise serve anything in the web root.
Cost at Different Traffic Levels
Self-hosting is sometimes chosen for cost, and the answer depends heavily on traffic. Monthly estimates for a 2,000-page docs site averaging 350 KB per page view, in USD at list prices:
| Monthly page views | Managed host (free/pro tier) | S3 + CloudFront | VM + CDN (free CDN tier) |
|---|---|---|---|
| 100,000 | 0 | ~4 | ~6 (smallest VM) |
| 1,000,000 | 0–20 | ~35 | ~6 |
| 10,000,000 | 20–plan limits | ~310 | ~12 (larger VM) |
The numbers above are estimates that move with provider pricing, but the shape is stable: managed hosts are cheapest at small and medium scale, S3 and CloudFront scale linearly with requests and bytes, and a VM behind a free CDN tier is nearly flat. Engineering time usually outweighs all three.
Logs and Observability
One genuine advantage of self-hosting is complete access to request logs. Managed hosts expose limited analytics; your own stack can keep every request. CloudFront standard logs (or real-time logs to Kinesis) and nginx or Caddy access logs record path, status, bytes, cache status and user agent for every hit. Three reports built from them pay for themselves quickly: 404s by path and referrer, which reveal broken links and missing redirects after a migration; cache hit ratio by path prefix, which catches a mis-set Cache-Control header before it becomes an origin bill; and top user agents by request volume, which separates aggressive crawlers from readers. Keep logs for thirty to ninety days and ship them somewhere queryable — Athena over S3 for CloudFront, or a lightweight pipeline such as Vector into ClickHouse or Loki for a VM. The reports themselves are described in Logging 404s at the Edge and Alerting on Cache Hit Ratio Drops. Mind privacy: IP addresses in access logs are personal data in many jurisdictions, so truncate or hash them at ingestion if you do not need them.
Previews and CI
Self-hosting does not have to mean giving up preview deploys. On S3, deploy each pull request to a pr-123/ prefix served by a preview distribution; on a VM, deploy to /srv/previews/pr-123/ and serve pr-123.preview.example.com with a wildcard certificate and a single server block that maps the subdomain to the directory. The GitHub Actions wiring is the same as for managed hosts — see Automating Preview Deploy Pipelines with GitHub Actions — with the upload step swapped for aws s3 sync or rsync. Clean up previews when pull requests close, exactly as described in Cleaning Up Stale Preview Deployments.
Operating It
Self-hosting adds operational work that managed hosts hide:
- Certificate renewal. Caddy and ACM renew automatically; certbot needs a timer and an alert if renewal fails.
- OS and server updates. A VM needs patching. Unattended security upgrades plus a monthly maintenance window cover most of it.
- Monitoring. Uptime checks, certificate expiry alerts and disk space on the VM (old releases accumulate). The monitoring section applies directly: Uptime and Synthetic Checks for Static Sites.
- Access. Deploys should use a dedicated user with write access only to the release directory, and credentials issued per run, as in Securing Deploy Credentials with GitHub OIDC.
Measured Impact
A 2,000-page documentation site moved from a managed host to S3 and CloudFront for compliance reasons. Performance from Lighthouse and RUM; deploy data from CI.
| Measure | Managed host | S3 + CloudFront |
|---|---|---|
| TTFB p75 (RUM, global) | 92 ms | 88 ms |
| LCP p75, mobile | 1.7 s | 1.7 s |
| Deploy time (upload + invalidation) | 48 s | 71 s |
| Rollback time | ~10 s (one click) | ~40 s (re-point origin path) |
| Setup effort | ~1 hour | ~3 days including Terraform and Function |
| Monthly cost (~1.2M page views) | 0 | ~42 USD |
Readers noticed nothing: edge performance was equivalent. The team paid in setup effort, slightly slower deploys and a monthly bill, and gained data residency, IAM-controlled access and CloudTrail audit logs, which were the reasons for the move.
Common Pitfalls
- Public S3 website endpoints. They serve over HTTP only and expose the bucket. Use a private bucket behind CloudFront with Origin Access Control.
- Forgetting clean URLs. Directory-style links return errors from S3 until a rewrite is added.
- nginx
add_headerinheritance. Headers set inservervanish in anylocationthat sets its own. Use an include file in each block. - One cache lifetime for everything. Hashed assets want a year; HTML wants revalidation. A single policy is wrong for one of them.
- No release retention. Symlink deploys fill the disk; keep the last ten releases and delete the rest.
- Rebuilding previews last. Losing preview deploys in a migration slows every review; plan them from the start.
Key Takeaways
- Self-hosting means rebuilding what managed hosts provide: TLS, clean URLs, redirects, headers, atomic deploys, rollback and previews.
- Caddy is the least configuration for a VM; nginx gives the most control; S3 with CloudFront scales without servers.
- Atomic deploys on a VM are a release directory and a symlink swap; rollback is re-pointing the symlink.
- Put a CDN in front of a VM for global readers; edge performance then matches managed hosts.
- Choose self-hosting for control, compliance or scale economics — not to make a small site faster.
FAQ
Why self-host a static site when managed hosts are free?
Common reasons are compliance or data-residency requirements, an existing cloud account and contract, predictable costs at very high traffic, integration with internal authentication, or needing full control over headers, logs and routing. For most small sites a managed static host remains simpler.
Which self-hosting option is simplest?
Caddy on a small VM. It serves files, obtains and renews TLS certificates automatically, compresses responses and supports clean URLs with a few lines of configuration. S3 with CloudFront scales further but needs more setup for URLs and redirects.
How do I get atomic deploys without a managed host?
On a VM, upload each build to a new release directory and switch a symlink to it, which is an atomic filesystem operation. On S3, upload hashed assets first and HTML last, or deploy to a versioned prefix and switch the CloudFront origin path.
Do I still need a CDN if I self-host on a VM?
For a global audience, usually yes. A single VM adds latency for distant readers and is a single point of failure. Putting a CDN in front with long cache lifetimes for hashed assets gives most of a managed host's performance.
What do managed hosts do that I will have to rebuild?
Atomic deploys and instant rollback, preview deploys per pull request, clean URL resolution, redirect rules, header configuration, TLS certificates and edge caching. Each is achievable when self-hosting, but each is work you now own.
Related
- Up: Production-Ready Deployment & CI/CD Workflows — deployment options in context.
- Deploying a Static Site to S3 and CloudFront — the AWS setup end to end.
- Serving a Static Site with Nginx — a complete server block.
- Serving a Static Site with Caddy — automatic TLS and minimal config.
- Clean URLs and Trailing Slashes on S3 — making directory URLs work.
- CloudFront Functions for Redirects — redirects at the edge.
- Zero-Downtime Deploys with Symlink Swaps — atomic releases on a VM.
- Atomic Deploys vs Incremental Uploads — why deploy atomicity matters.