Static hosting platform: one CloudFront gateway, many demo apps, shared S3 bucket

Static hosting for vibe coders: one platform, many demo apps

Why I built this

While working on a dev platform, we once got a request that sounded simple on the surface: a super lightweight “vibe coding” space — friendly for non-technical people who wanted to sketch an idea quickly and see it live without learning deployment tooling.

There was no formal spec. The practical bar was roughly:

That idea stuck with me, but this article is not about what we shipped at work. The team eventually took a different road — a heavier stack that kept picking up “one more technology” as requirements shifted late in the game. Fair enough for that context; it just was not the minimal static-hosting shape I had in mind.

This platform is a private experiment on my own time and AWS account: a way to satisfy builder curiosity and see how small the solution could stay while still feeling useful. Same spark, different constraints — optimize for cheap, boring, and agent-friendly instead of covering every platform feature on day one.

At its core it is still a hosting problem dressed as a product ask: you need a URL fast, you need previews while experimenting, and you do not want every experiment to become its own infrastructure project.

From one app to many

A few years earlier I had built static hosting with a protected dev environmentone app, multiple environments, and preview-style URLs on a dev subdomain. It was simple, cheap, and dynamic enough for feature work.

That design stuck in my head. The new question was:

What if we had the same preview-by-branch idea, but for any number of small static apps — not just one codebase?

That became this platform: still boring on purpose (S3 + CloudFront + GitHub Actions), but scaled out to many tenants with almost no ceremony per app.

What this is — and is not

This is a static hosting platform, not a full-stack product:

The goal is “draft, deploy, share a link, iterate” — not “run my entire SaaS here.”

What shipped

The code lives in two places:

Live examples today on demo.oleksiipopov.com:

AppTypeProduction URL
helloBundled with platform CDKhello.demo.oleksiipopov.com
paletteBundled with platform CDKpalette.demo.oleksiipopov.com
hosting-demoExternal repo + reusable workflowhosting-demo.demo.oleksiipopov.com
hosting-demo (branch preview)Same app, PR deployhosting-demo--test-feature-branch-hosting.dev.demo.oleksiipopov.com

What I needed (requirements)

The user-facing requirements were almost embarrassingly small:

AskHow the platform answers it
“Give me a link when you’re done”Production URL per app slug; PR workflow posts a preview URL in a comment
“Let me iterate”Each branch/PR gets its own subdomain and S3 prefix
“Keep it simple for agents”App repo adds a short reusable workflow; build is pnpm build → upload dist/

From that, a few technical requirements fell out:

  1. Multi-tenant static hosting — many small apps, each with its own slug, without a new CloudFront distribution per app.
  2. Production vs preview — default branch is production; every PR gets an isolated preview folder and URL.
  3. Predictable URLs — wildcards on DNS and ACM: *.demo.oleksiipopov.com and *.dev.demo.oleksiipopov.com.
  4. Safe names — Git branch names like feature/login must become DNS-safe (feature-login).
  5. SPA-friendly 404s — ship 404.html alongside index.html so client-side routers still work.
  6. Simple app integrationdeploy.yml + optional cleanup.yml; no platform code copied into every repo.
  7. Automatic cleanup — when a remote branch is deleted, remove the S3 prefix so previews do not pile up.
  8. Low cost and low ops — one bucket, one distribution, OIDC from GitHub Actions (no long-lived keys in app repos).

Explicit non-goals: backends, databases, per-app auth, custom domains per app, and per-app Cache-Control tuning (shared deploy workflows invalidate CloudFront after upload instead). This stays a static lane on purpose.

Architecture at a glance

Visitors always hit one CloudFront distribution. A CloudFront Function (viewer-request) reads the Host header and rewrites the URI to an S3 prefix. Lambda@Edge (origin-response) only helps with HTML-style 404 handling by serving the closest 404.html inline.

AWSGitHubUserss3 sync + invalidationHTTPSrewrite URI prefixBrowserApp repo workflowPlatform reusableworkflowsS3 bucketCloudFrontCloudFront FunctionLambda Edge 404 resolverSSM parameters

Infra IDs in SSM

The PW --> SSM arrow is how reusable workflows learn where to upload and what to invalidate. When CDK deploys the platform stack, it writes two Parameter Store values:

ParameterValue
/static-hosting/bucket-nameShared S3 bucket name
/static-hosting/distribution-idCloudFront distribution ID

App repos do not copy those IDs into GitHub secrets. They call deploy-app.yml (or cleanup-branch.yml / invalidate-app.yml) with secrets: inherit; the workflow assumes the shared OIDC role, runs aws ssm get-parameter for each path, then uses the results for s3 sync and create-invalidation.

That keeps a single source of truth in AWS: if infra is recreated, updating the parameters is enough — every consumer workflow keeps working without editing each app repo. Apps only pass their inputs (app-slug, output-dir, base-domain, …).

URL → S3 mapping

Production uses a short hostname. Previews use a single DNS label with -- between app and branch (so *.dev.demo... wildcard still works).

URLS3 prefix (example)
hello.demo.oleksiipopov.com/hello/main/
hello--feat-ui.dev.demo.oleksiipopov.com/hello/feat-ui/
hosting-demo.demo.oleksiipopov.com/hosting-demo/main/
hosting-demo--test-feature-branch-hosting.dev.demo.oleksiipopov.com/hosting-demo/test-feature-branch-hosting/
Previewhosting-demo--test-feature-branch-hosting.dev.demo.../hosting-demo/test-feature-branch-hosting/...Productionhello.demo.../hello/main/index.html

Branch names are sanitized in CI (slashes and odd characters become hyphens, lowercased, max 63 chars). The separator -- is reserved: app slugs and branch names cannot contain -- themselves.

Closest 404 (HTML only)

For navigation requests (no file extension, .html, or Accept: text/html), a miss in S3 triggers a search:

  1. /{app}/{branch}/404.html
  2. /{app}/404.html
  3. /404.html (global)

The first existing file is returned with HTTP 404 and the HTML body — no redirect. Static assets (.js, .css, images) keep a normal 404 from origin.

SPA tip: ship 404.html as a copy of index.html in each deploy. The external demo app build does this automatically.

What we deliberately avoid

CloudFront custom error responses are not configured on the distribution. They would fight with the Lambda@Edge resolver.

Demo apps: inside the platform vs outside

Inside the platform (CDK bootstrap)

When the CDK stack deploys, it uploads two tiny sample apps from packages/infra/assets/demo-apps/:

Hello demo app — bundled sample
Palette demo app — bundled sample with JS

These prove routing and caching without any GitHub app repo. They are good smoke tests after infra changes.

Outside the platform (real consumer)

static-hosting-demo-app is a minimal SPA in its own repository. Its workflow calls the platform’s reusable deploy-app.yml:

jobs:
  deploy:
    uses: AlexeyPopovUA/static-hosting-for-vibe-coders/.github/workflows/deploy-app.yml@main
    with:
      app-slug: hosting-demo
      build-command: pnpm build
      output-dir: dist
      base-domain: demo.oleksiipopov.com
    secrets: inherit
jobs:
  deploy:
    uses: AlexeyPopovUA/static-hosting-for-vibe-coders/.github/workflows/deploy-app.yml@main
    with:
      app-slug: hosting-demo
      build-command: pnpm build
      output-dir: dist
      base-domain: demo.oleksiipopov.com
    secrets: inherit

secrets: inherit passes the shared AWS_AUTH_ROLE for OIDC — not bucket or distribution IDs. The reusable workflow looks those up from SSM at runtime.

On every pull request, the workflow builds, syncs to s3://…/hosting-demo/{sanitized-branch}/, invalidates CloudFront paths, and comments the preview URL on the PR.

Cleanup is a sibling workflow on delete (branch removed), calling cleanup-branch.yml with the same app-slug and github.event.ref.

Branch preview in practice (verified)

To prove the external app path end-to-end, I opened PR #2 on branch test/feature-branch-hosting:

  1. Added a visible yellow badge and data-e2e="feature-branch-preview" in the app HTML (so production and preview are easy to tell apart).
  2. Pushed the branch — GitHub Actions ran the reusable deploy-app.yml workflow successfully (~22s).
  3. The PR received an automatic comment with the preview URL.

The branch name test/feature-branch-hosting was sanitized to test-feature-branch-hosting for both the S3 prefix and the subdomain:

Git branchSanitizedPreview URL
test/feature-branch-hostingtest-feature-branch-hostinghttps://hosting-demo--test-feature-branch-hosting.dev.demo.oleksiipopov.com
Branch preview of hosting-demo with E2E badge

Checks that passed:

When the remote branch is deleted, cleanup.yml should remove the prefix and the preview hostname should return 404. The repo README has a manual checklist to repeat the full open → verify → delete branch → verify cleanup flow.

CI/CD flow

CloudFrontS3Platform scriptsGitHub ActionsApp repoDeveloperCloudFrontS3Platform scriptsGitHub ActionsApp repoDeveloperalt[Pull request]Push branch / open PRdeploy.yml (workflow_call)pnpm install + buildcheckout platform (validation scripts)sanitize branch names3 sync to app branch prefixCloudFront invalidationComment preview URL

Platform repo workflows (same repository):

WorkflowRole
deploy-infra.ymlCDK deploy when infra changes
deploy-app.ymlReusable build + sync + invalidate + PR comment
cleanup-branch.ymlReusable delete prefix for one app/branch
invalidate-app.ymlManual cache bust
validate-infra.ymlTypecheck, tests, synth on PRs

Apps need pnpm, AWS_AUTH_ROLE (OIDC), and permissions: id-token: write.

S3 layout (mental model)

/ (bucket root)404.html (global fallback)/hello/404.htmlmain/ productionfeat-x/ optional preview/hosting-demo/404.htmlmain/test-feature-branch-hosting/active preview

Each app should deploy its own 404.html at the app root (/{app}/404.html) when possible, plus per-branch copies for SPA routing.

Caching (short version)

How this compares to my older hosting article

TopicOlder simple hostingThis platform
AppsOne site, path-based branches on dev hostMany apps, subdomain per app
Dev accessWAF IP allowlist on dev distributionPublic preview URLs on *.dev...
DistributionsTwo (prod + dev)One
App reposSame repo / manual deploySeparate repos + reusable workflows

The older design is still valid when you need a private dev environment. This platform optimizes for many public previews and minimal ceremony per new demo.

Tech stack

Full specification (kept in sync with code): docs/SPEC.md.

Adding your own app

  1. Create a static app repo with pnpm build output (e.g. dist/).
  2. Pick an app-slug (lowercase, hyphens, no --).
  3. Copy deploy.yml and optional cleanup.yml from the demo app.
  4. Set AWS_AUTH_ROLE and ensure OIDC trust on the shared IAM role.
  5. Push to main for production; open a PR for a preview URL.

The platform repo includes a create-demo-app skill/script to scaffold a new consumer repo.

Conclusion

The original ask was not “build a PaaS.” It was: let people (and agents) draft ideas, get a link, and iterate — without a deployment lecture.

This platform is the smallest thing that fit: one bucket, one distribution, predictable URLs, thin app repos, and branch previews that work the same way for every static app. Demo apps inside the CDK package prove routing; the external hosting-demo repo proves real consumers can plug in with a few lines of YAML.

If you are in the same situation — many small front ends, AI-assisted workflows, and a need to share progress as a URL — a shared subdomain router on CloudFront is a solid fit. Just keep the scope static, invest in sanitization and cleanup early, and accept that anything needing a backend belongs somewhere else.

Repositories: platform · external demo app

Table of Contents

Navigate through the article

Developed by Oleksii Popov
2026