Cloud

Hosting marketing sites on S3 instead of a full server

CLD

A lot of the marketing sites we build do not need a running server at all — they are HTML, CSS, and a bit of jQuery, generated once and rarely changing between deploys. For a handful of these this year we skipped the usual Nginx-on-EC2 setup and served the site directly from an S3 bucket configured for static website hosting, with CloudFront in front for HTTPS and caching.

What the setup actually involves

  • An S3 bucket with static website hosting enabled, holding the built HTML, CSS, JS, and image assets, with a bucket policy scoped to allow public reads of the site content only
  • A CloudFront distribution in front of the bucket, both for HTTPS (S3's own website endpoints do not support it directly) and for edge caching closer to visitors around the world
  • Route 53 pointed at the CloudFront distribution rather than the bucket directly, with an ACM certificate provisioned for the custom domain
  • A simple cache-invalidation step in our deploy script so CloudFront does not keep serving a stale version of a page after a new deploy, since without it a visitor could see an old homepage for up to a day depending on the cache headers we set

Why this beats a small EC2 box for this use case

Deploys became a single `aws s3 sync` command from our build machine, uploading only the files that changed since the last deploy, followed by a CloudFront invalidation for the handful of paths that changed. There is no server to patch, no uptime monitoring for a box that is just serving static files, and the bill for a typical marketing site now runs a few dollars a month instead of an always-on instance that mostly sits idle between traffic spikes from a marketing campaign.

We have also stopped worrying about a marketing site going down because of an unrelated server issue — a WordPress plugin update gone wrong, an OS package needing a security patch, a runaway process eating memory on a shared box. Since there is no server-side software running at all, that entire category of incident simply does not exist for these sites anymore, which has been a genuine relief for the handful of clients whose marketing sites we used to get paged about at odd hours for reasons that had nothing to do with their actual content.

Handling the pieces that need a server

For anything that needs a contact form we point at a small Lambda function or a third-party form service rather than standing up a server just for that. One client's site needed a slightly more involved lead-capture form that posts to a CRM, and rather than reconsidering the whole static architecture, we wrote a small Lambda function behind API Gateway that validates the submission and forwards it, keeping the rest of the site fully static. This has become our default pattern: keep the site static, and reach for a narrow serverless function only for the specific piece of interactivity that actually needs one.

Redirects needed a bit more thought than they would on a normal web server, since S3's own redirect rules are limited compared to what an Nginx config can express. For sites migrating from an older platform with a long tail of legacy URLs to preserve for SEO, we handle the bulk of redirect logic at the CloudFront layer using a small Lambda@Edge function rather than trying to force S3's native redirect rules to cover every case.

Where it does not fit

For sites that genuinely need server-rendered personalization or A/B testing logic, this approach is the wrong fit entirely — you would be fighting the architecture rather than working with it, and every piece of dynamic behavior becomes its own separate serverless function to build, test, and maintain, which stops being simpler than just running a small server past a certain point. We also would not use this for a site the client's own team edits directly through a CMS admin interface, since there is no server-side application sitting behind static files to log into; those sites still get a proper WordPress setup on a real server, with the tradeoffs that come with it.

What clients have noticed

For the narrow case of a marketing site with content that changes only through a redeploy, though, this has become our default recommendation, and clients have generally been pleased with both the lower ongoing hosting cost and the near-instant page loads that come from serving cached static assets at the edge. One client specifically called out how much faster their site felt on mobile after the migration, which we attribute mostly to CloudFront's edge caching putting content physically closer to visitors than a single EC2 region ever could, combined with simply having far less to load in the first place once the WordPress admin, plugins, and database queries were no longer part of every page request.

Build pipeline considerations

Because the site is generated once and synced up rather than rendered live, our build pipeline became a more important piece of the picture than it would be for a traditional server-rendered site. We standardized on a small Node-based static site generator across these projects rather than hand-writing HTML, specifically so content that repeats across pages — navigation, footer, shared metadata — lives in one template rather than being copy-pasted across dozens of static files and inevitably drifting out of sync the first time someone updates a phone number in the footer of one page but not the others.

Cache headers needed more deliberate thought than we initially gave them. We set long cache lifetimes on versioned, fingerprinted asset files (CSS and JS bundles with a content hash in the filename) since those never change once built, but much shorter cache lifetimes on the HTML files themselves, so a content update actually reaches visitors promptly rather than being masked by an aggressive cache that outlives the deploy that superseded it. Getting this balance wrong in the early days of this pattern meant one client's site briefly showed outdated pricing after a legitimate content update, until we tightened the HTML cache lifetime and made the CloudFront invalidation step a required part of every deploy rather than an optional one a developer might forget to run.

Monitoring a site with no server to monitor

The absence of a server does not mean the absence of things worth watching. We still track uptime and response time from an external monitoring service pointed at the live domain, since CloudFront and S3 can both have their own outages even though we do not operate either directly, and we still want to know within minutes if a client's site becomes unreachable. We also monitor the build pipeline itself — a failed deploy that never reaches S3 is a different failure mode than a live outage, but just as capable of leaving a client's site stuck showing stale content indefinitely if nobody notices.

A migration checklist we now reuse

For any client moving from a traditional server-hosted setup to this pattern, we now work through a short standard checklist covering domain and DNS cutover timing, redirect mapping for any URLs that are changing shape, a plan for the contact form or other dynamic pieces, and a rollback plan in case something about the new setup surfaces an issue we did not anticipate during testing. Having this checklist in place has made each successive migration noticeably smoother than the first one, which we largely improvised as we went.

Explaining the tradeoff to a skeptical client

Not every client is immediately comfortable with the idea of a site with "no server," and a few conversations early on required more explanation than we expected. The framing that has worked best is comparing it to what already happens conceptually with a CDN in front of a normal server — most clients already understand that their images and static assets are cached at edge locations closer to visitors — and explaining that this approach simply extends that same caching to the entire page rather than just the assets on it, with the "origin" being a storage bucket instead of a running application server. Once framed that way, the idea stops sounding exotic and starts sounding like a natural extension of infrastructure they were already implicitly relying on.

Cost comparison over a full year

We went back and compared a full year of hosting costs for three client sites before and after this migration, and the difference was more dramatic than the per-month sticker price alone suggested. Beyond the direct EC2-versus-S3-and-CloudFront comparison, the previous setup also carried recurring costs we had not been billing separately but were absorbing as time: routine security patching, occasional emergency response to a compromised WordPress plugin, and SSL certificate renewal that, before ACM's free certificates, needed manual attention every year. None of those costs exist in the static setup, and accounting for that engineering time honestly made the total cost difference considerably larger than a simple hosting-bill comparison would show.

A note on search engine crawling

Because these are marketing sites depending heavily on organic search traffic, we paid particular attention to making sure the migration did not disturb crawling and indexing. Serving pre-rendered static HTML is, if anything, friendlier to search engine crawlers than a dynamically rendered page, since there is no server-side rendering time for a crawler to wait on and no risk of a slow database query timing out mid-crawl. We have not seen a negative ranking impact on any site migrated this way, and for at least one client, page speed improvements that followed the migration appear to have contributed to a modest ranking improvement on a couple of their most competitive keyword targets, though we are careful not to overstate causation on a single data point like that.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients