# 4. Deploy to Cloudflare Pages, Vercel, Netlify, Firebase Hosting, Render, DigitalOcean App Platform, GitHub Pages, S3 + CloudFront, Bunny.net, nginx, Docker, or Fly.io **Goal:** `dist/` live on the internet, rebuilt on every Git push. Laurel emits plain static files. Any static host or web server will serve them. The configs below are the minimum to get a working CI build on each major free-tier host, plus Render Static Sites, DigitalOcean App Platform, Firebase Hosting, AWS-native S3 + CloudFront, Bunny.net Storage + CDN, and self-hosted nginx quickstarts. Docker is covered as both a runtime wrapper around a pre-built `dist/` directory and a multi-stage Bun build + nginx serve image; Laurel ships [`examples/docker/Dockerfile`](../../examples/docker/Dockerfile), [`examples/docker/Dockerfile.multi-stage`](../../examples/docker/Dockerfile.multi-stage), [`examples/docker/.dockerignore`](../../examples/docker/.dockerignore), and [`examples/docker/nginx.conf`](../../examples/docker/nginx.conf), and a reverse-proxy compose snippet at [`examples/docker/docker-compose.yml`](../../examples/docker/docker-compose.yml). Fly.io is covered as a container runtime around that pre-built output, using Laurel's generated `dist/.laurel/nginx.conf` for redirects and headers. **Universal pre-flight:** ```bash bunx laurel build # confirm a green build locally first ls dist/ # sanity-check the output ``` Then commit the entire project (excluding `dist/` and `node_modules/`) to a Git repo on GitHub, GitLab, or wherever your chosen host integrates with. `init` already wrote a sensible `.gitignore`. If you deploy to a path other than `/` (e.g. `https://example.com/blog/`), set `[build] base_path = "/blog/"` in `laurel.toml` and update `[site] url` accordingly. --- ## Docker / nginx container **Recommended for:** hosts that require a container image, or local smoke tests of the built static output. For the focused Docker guide, including the sample [`examples/docker/Dockerfile`](../../examples/docker/Dockerfile), [`examples/docker/Dockerfile.multi-stage`](../../examples/docker/Dockerfile.multi-stage), [`examples/docker/.dockerignore`](../../examples/docker/.dockerignore), [`examples/docker/nginx.conf`](../../examples/docker/nginx.conf), [`examples/docker/docker-compose.yml`](../../examples/docker/docker-compose.yml), and nginx config caveats, see [`docs/deploy/docker.md`](../deploy/docker.md). Laurel does not build inside a container by default. Build first, then mount `dist/` into an external nginx container: ```bash bunx laurel build docker run --rm \ --name laurel-static \ -p 8080:80 \ -v "$PWD/dist:/usr/share/nginx/html:ro" \ nginx:alpine ``` Open `http://localhost:8080/`. This minimal command uses nginx's stock config, so it does not apply Laurel-generated redirects, cache headers, or security headers. For a reusable image, copy `examples/docker/Dockerfile` and `examples/docker/nginx.conf` into your project root after building `dist/`, then run `docker build`. If your host expects the Docker build itself to install dependencies and build the site, copy [`examples/docker/Dockerfile.multi-stage`](../../examples/docker/Dockerfile.multi-stage) and [`examples/docker/.dockerignore`](../../examples/docker/.dockerignore) instead. It runs `bun install`, `bunx laurel build`, then copies the generated `dist/` into the same nginx runtime image while leaving host-local `.git/`, `node_modules/`, and `dist/` out of the build context. For a closer self-hosted nginx setup, enable the existing nginx emitter: ```toml [deploy.nginx] enabled = true root = "/var/www/laurel" server_name = "_" ``` Then rebuild and use `dist/.laurel/nginx.conf` with an nginx image compatible with the generated directives. The generated config includes `brotli_static`; the stock `nginx:alpine` image may not include that module, so treat the default-config command above as the portable smoke test. --- ## Fly.io **Recommended for:** teams that want Fly's container rollout model, regions, and TLS while serving a static Laurel build. For the focused Fly guide, including the generated nginx config flow, see [`docs/deploy/fly.md`](../deploy/fly.md). Enable the nginx deploy target with the document root used inside the Fly container: ```toml [deploy.nginx] enabled = true root = "/usr/share/nginx/html" server_name = "_" ``` Copy the sample runtime files to the project root: ```bash cp examples/fly/fly.toml fly.toml cp examples/fly/Dockerfile Dockerfile ``` The sample `Dockerfile` serves the already-built `dist/` directory with nginx and copies `dist/.laurel/nginx.conf` into `/etc/nginx/conf.d/default.conf`. That generated config translates `redirects.yaml` and `[deploy.headers]` into nginx rules for Fly. The checked-in `examples/fly/nginx.conf` remains available only as a static fallback if you intentionally do not enable `[deploy.nginx]`. Create the Fly app once: ```bash flyctl launch --no-deploy ``` Then edit `app` and `primary_region` in `fly.toml`. The sample points Fly's HTTP service at nginx port 80: ```toml app = "my-laurel-site" primary_region = "sjc" [build] dockerfile = "Dockerfile" [http_service] internal_port = 80 force_https = true auto_stop_machines = "stop" auto_start_machines = true min_machines_running = 0 [[http_service.checks]] interval = "30s" timeout = "5s" grace_period = "10s" method = "GET" path = "/healthz" ``` The Fly check targets nginx's lightweight `/healthz` endpoint, which the generated `dist/.laurel/nginx.conf` serves with `200 ok`. Copy [`examples/ci/fly.yml`](../../examples/ci/fly.yml) to `.github/workflows/fly.yml`, add repository secret `FLY_API_TOKEN`, and push to `main`. The workflow builds `dist/` with Bun, then runs `flyctl deploy --remote-only`. The sample removes `brotli_static` from the generated config during Docker build because stock `nginx:alpine` does not ship the Brotli static module. Use a Brotli-enabled nginx image and remove that `sed` line if your Fly runtime should serve `.br` sidecars directly. --- ## Cloudflare Pages **Recommended for:** global CDN edge, generous free tier, zero config. For the full Cloudflare-specific guide, including `laurel deploy cloudflare`, see [`docs/deploy/cloudflare-pages.md`](../deploy/cloudflare-pages.md). 1. Cloudflare dashboard → **Workers & Pages → Create → Pages → Connect to Git**. 2. Pick your repo. In the build configuration screen: | Field | Value | | ------------------------ | --------------------------- | | Framework preset | *None* | | Build command | `bunx laurel build` | | Build output directory | `dist` | | Root directory | *(blank, unless monorepo)* | 3. Environment variables → add **`BUN_VERSION` = `1.3.0`** (or your preferred ≥1.3 version). Cloudflare's build image will install Bun automatically when it sees this variable. 4. Save and deploy. First build takes ~1 minute; subsequent builds are cached. The `_redirects` and `_headers` files at the root of `dist/` are picked up by Cloudflare. Set `[deploy.cloudflare_pages].enabled = true` in `laurel.toml` and Laurel will write `_headers` plus `_routes.json` on every build. Custom redirects go in a `redirects.yaml` at the project root; the default `[components.redirects]` emitter writes them to `dist/_redirects`. Supported status codes are 301, 302, 307, and 308; the first rule per `from` wins on overlap. For branch preview builds, Cloudflare Pages sets `CF_PAGES_BRANCH`. Laurel marks non-`main` / non-`master` branches (or branches other than `CF_PAGES_PRODUCTION_BRANCH` when set) as preview builds, injects ``, and emits `X-Robots-Tag: noindex` through `dist/_headers` so crawlers do not index the preview URL. Laurel also emits `dist/404.html` on every build. Because the Cloudflare Pages build output directory is `dist`, that file is deployed at the publish root as `404.html`; Pages automatically uses it as the custom 404 page for unmatched static routes. You do not need a catch-all `_redirects` rewrite to route missing paths to `/404.html`. ```toml [deploy.cloudflare_pages] enabled = true ``` If you deploy `dist/` through Cloudflare Workers Static Assets rather than Pages, configure the asset bundle to use Laurel's generated 404 page: ```toml [assets] directory = "./dist" not_found_handling = "404-page" ``` Laurel is a multi-page static site, so direct navigation should resolve the matching HTML file and real misses should use `dist/404.html`. Avoid `not_found_handling = "single-page-application"` for normal Laurel deploys because it can serve the homepage for missing navigation requests and weaken the intended 404 behavior. ```yaml # redirects.yaml - from: /feed to: /rss.xml status: 301 - from: /old-post to: /new-post ``` For direct deploys outside the Git-connected Pages build, configure the Pages project name and let Laurel call Wrangler: ```toml [deploy.cloudflare] project_name = "my-blog" ``` ```bash bunx laurel deploy cloudflare --build ``` Advanced users who want GitHub Actions to call Wrangler directly can copy [`examples/ci/cloudflare-pages.yml`](../../examples/ci/cloudflare-pages.yml) to `.github/workflows/cloudflare-pages.yml`. It builds `dist/`, then runs `wrangler pages deploy dist --project-name=...` through `cloudflare/wrangler-action`. If Wrangler should also manage Pages Functions, bindings, or compatibility settings, copy [`examples/deploy/cloudflare-pages/wrangler.toml`](../../examples/deploy/cloudflare-pages/wrangler.toml) to the project root and keep its `name` aligned with the workflow's `CLOUDFLARE_PROJECT_NAME`. ### Cloudflare Pages + R2 for large image libraries Cloudflare Pages accepts at most 25,000 files per deploy. If `dist/content/images/` contains thousands of responsive image variants, keep the static site on Pages and sync only the image subtree to Cloudflare R2. First check the build size: ```bash bunx laurel build find dist -type f | wc -l ``` Then create an R2 bucket, generate an R2 API token, and sync images with the R2 S3-compatible endpoint: ```bash export AWS_ACCESS_KEY_ID= export AWS_SECRET_ACCESS_KEY= export AWS_DEFAULT_REGION=auto aws s3 sync dist/content/images/ s3://my-blog-images/content/images/ \ --endpoint-url https://.r2.cloudflarestorage.com \ --delete ``` Move `dist/content/images/` out of the Pages upload before `wrangler pages deploy`, then restore it for the R2 sync step. A Worker mounted on `/content/images/*` can read the private R2 bucket and keep image URLs same-origin. Laurel also has an R2 deploy target: ```toml [deploy.r2] bucket = "my-blog-static" endpoint = "https://.r2.cloudflarestorage.com" delete = true ``` ```bash bunx laurel deploy r2 --build --dry-run ``` That command syncs the whole build output directory (`dist/` by default) to the bucket root. For the Pages + R2 split, use the scoped `aws s3 sync dist/content/images/ ...` command above. See [`docs/deploy/cloudflare-pages-r2-images.md`](../deploy/cloudflare-pages-r2-images.md) for the full Worker, custom-domain, and CI workflow. --- ## Vercel **Recommended for:** the simplest end-to-end Git-to-URL flow. For the full Vercel-specific guide, including generated `vercel.json`, prebuilt GitHub Actions deploys, and `laurel deploy vercel`, see [`docs/deploy/vercel.md`](../deploy/vercel.md). Enable the Vercel emitter in `laurel.toml` so builds include Vercel-formatted headers and redirects: ```toml [deploy.vercel] enabled = true ``` 1. **Import Project** → select your repo. 2. **Framework Preset → Other**. 3. Build & Output settings: | Field | Value | | ----------------- | -------------------- | | Build command | `bunx laurel build` | | Output directory | `dist` | | Install command | *(leave blank — Vercel auto-detects Bun via `bun.lock`)* | 4. **Deploy**. Vercel reads `bun.lock` and uses Bun automatically. No environment variable required for the default Git-connected build. For preview deployments, Vercel sets `VERCEL_ENV=preview`. Laurel injects `` and emits `X-Robots-Tag: noindex` through `dist/vercel.json`, even when `[deploy.vercel].enabled` is not set, so preview URLs are not indexed. `VERCEL_ENV=production` does not get these markers. Laurel is not a Next.js project; it builds static files into `dist/` directly. Do not add Next.js `output: 'export'`, `next.config.js`, or Vercel adapter settings for this deploy path. Use Vercel's `Other` preset with the `dist` output directory instead. Every Laurel build includes `dist/404.html`. On Vercel, that root-level file is served as the custom not-found page for unmatched static routes, with a 404 status, so the standard setup above does not need a catch-all rewrite for `404.html`. Custom redirects go in `redirects.yaml`; with `[deploy.vercel].enabled = true`, Laurel folds them into `dist/vercel.json` alongside cache and security headers. Supported status codes are 301, 302, 307, and 308; the first rule per `from` wins on overlap. Vercel always applies redirects even when a static file exists at the source path, so `force` is only informational on this target. ```yaml # redirects.yaml - from: /feed to: /rss.xml status: 301 - from: /old-post/ to: /new-post/ status: 308 ``` For direct deploys outside the Git-connected Vercel build, let Laurel call the Vercel CLI: ```bash bunx laurel deploy vercel --build ``` The command runs `laurel build`, checks for `dist/.laurel-manifest.json`, then executes `vercel deploy dist --prod`. Set `VERCEL_TOKEN` in CI, and use `bunx laurel deploy vercel --dry-run` to audit the command before uploading. --- ## Firebase Hosting **Recommended for:** teams already using Firebase projects and custom domains. For the full Firebase-specific guide, including generated `firebase.json`, redirect notes, and header behavior, see [`docs/deploy/firebase-hosting.md`](../deploy/firebase-hosting.md). Laurel does not currently have a `laurel deploy firebase` command. Enable the Firebase emitter, build the static site, then let the Firebase CLI upload `dist/`: ```toml [deploy.firebase] enabled = true ``` ```bash bunx laurel build cd dist firebase deploy --only hosting ``` The emitter writes `dist/firebase.json` with `hosting.public = "."`, shared headers, redirects, `cleanUrls: true`, the configured trailing-slash policy, and an empty `rewrites` array so Laurel is not treated as an SPA. For GitHub Actions, copy [`examples/ci/firebase.yml`](../../examples/ci/firebase.yml) to `.github/workflows/firebase.yml`. The workflow builds with Bun, checks that `dist/firebase.json` exists, then runs FirebaseExtended/action-hosting-deploy with `channelId: live` and `entryPoint: dist`. --- ## Netlify **Recommended for:** form handling, branch previews, plugin ecosystem. Enable the Netlify emitter in `laurel.toml` so builds include the generated `_headers` file and Netlify-formatted redirects: ```toml [deploy.netlify] enabled = true ``` Then copy [`examples/deploy/netlify/netlify.toml`](../../examples/deploy/netlify/netlify.toml) to `netlify.toml` at the repo root: ```toml [build] command = "bunx laurel build" publish = "dist" [build.environment] BUN_VERSION = "1.3.0" ``` Then **Netlify dashboard → Add new site → Import from Git → pick repo → Deploy**. The `netlify.toml` overrides any guesses Netlify makes. For deploy previews and branch deploys, Netlify sets `DEPLOY_PRIME_URL`. Laurel uses that value automatically as `site.url` for the build, falling back to `DEPLOY_URL` and then `URL` if needed. Canonical links, `og:url`, RSS, robots, and sitemap output therefore point at the preview hostname. Explicit overrides still win: `--base-url` takes precedence over `LAUREL_BUILD_BASE_URL`, then `LAUREL_SITE_URL`, then the Netlify deploy URL, then the configured `[site] url`. Laurel also treats Netlify `deploy-preview` and `branch-deploy` contexts as preview builds: it injects `` and emits `X-Robots-Tag: noindex` through `dist/_headers`, even when `[deploy.netlify].enabled` is not set. Netlify `production` context is left indexable. Laurel emits `dist/404.html` on every build. If your theme provides `error-404.hbs`, that template becomes the file; otherwise Laurel writes a default branded noindex page. Netlify automatically uses a publish-root `404.html` as the custom response body for unmatched paths, so do not add a catch-all `/* /404.html 404` redirect unless you are intentionally replacing Netlify's built-in static 404 behavior. Netlify's Bun support is via the `BUN_VERSION` build environment variable — without it, the build runs Node and `bunx` will fail. The sample also shows where optional Netlify build plugin blocks belong; Laurel build-time plugins stay in `laurel.toml`'s top-level `plugins` array. Custom redirects go in `redirects.yaml`; Laurel emits them to `dist/_redirects` when `[deploy.netlify].enabled = true`. Netlify's `force: true` semantics are supported via the `!` status suffix: ```yaml # redirects.yaml - from: /feed to: /rss.xml status: 301 force: true ``` For CI-driven deploys, Netlify CLI uploads, and header customization details, see [`docs/deploy/netlify.md`](../deploy/netlify.md). --- ## DigitalOcean App Platform **Recommended for:** teams already using DigitalOcean and wanting a managed Git-connected static site. For the focused App Platform guide, including the current no-App-Spec-emitter status, see [`docs/deploy/digitalocean-app-platform.md`](../deploy/digitalocean-app-platform.md). In DigitalOcean, create an App Platform app from your Git repository and configure the resource as a static site: | Field | Value | | --- | --- | | Source directory | `/` unless Laurel lives in a monorepo subdirectory | | Build command | `bunx laurel build` | | Output directory | `dist` | DigitalOcean can scan for common static output directories, including `dist`, but setting it explicitly makes the deploy contract clear. App Platform detects Bun from `bun.lock` / `bun.lockb`; set a build-time `BUN_VERSION` environment variable if you want to pin the builder. Laurel does not currently emit `.do/app.yaml`, DigitalOcean headers, or DigitalOcean redirects; keep any App Spec sample minimal and configure App Platform-owned behavior in DigitalOcean or an external edge layer. ```yaml name: my-laurel-site static_sites: - name: web github: repo: your-org/your-repo branch: main deploy_on_push: true source_dir: / build_command: bunx laurel build output_dir: dist envs: - key: BUN_VERSION value: "1.3.0" scope: BUILD_TIME ``` --- ## Render Static Sites **Recommended for:** simple Git-connected static hosting on Render, especially when the site lives next to other Render services. For the focused Render guide, including the optional deploy-hook workflow and current header / redirect limitations, see [`docs/deploy/render.md`](../deploy/render.md). 1. Render dashboard -> **New -> Static Site**. 2. Connect the Git repo that contains the Laurel project. 3. In the service settings: | Field | Value | | ----------------- | ------------------------------------------------------ | | Build command | `bun install --frozen-lockfile && bunx laurel build` | | Publish directory | `dist` | | Root directory | *(blank, unless monorepo)* | 4. Environment variables -> add **`BUN_VERSION` = `1.3.0`**. 5. Save and deploy. If you prefer Render Blueprints, copy [`examples/render/render.yaml`](../../examples/render/render.yaml) to `render.yaml` at the repository root. The sample defines a Static Site service that runs `bun install && bun run build` and publishes `./dist`. Render serves the generated `dist/` directory directly. Laurel does not currently emit a Render-specific `render.yaml`, nor does it translate `[deploy.headers]` or `redirects.yaml` into Render-native dashboard rules. Configure custom headers and redirects in Render for now. The optional [`examples/ci/render.yml`](../../examples/ci/render.yml) workflow can build `dist/` in GitHub Actions before calling a Render deploy hook, but Render still performs the final checkout, build, and publish. --- ## GitHub Pages **Recommended for:** repos already on GitHub, no extra account needed. GitHub Pages does not run Bun, so the build has to happen in GitHub Actions and the resulting `dist/` is uploaded as the Pages artifact. For the focused quickstart, custom-domain notes, and branch-deploy caveats, see [`docs/deploy/github-pages.md`](../deploy/github-pages.md). The minimal workflow setup is included here so this tutorial stays self-contained. Copy [`examples/ci/github-pages.yml`](../../examples/ci/github-pages.yml) to `.github/workflows/pages.yml` in your repo — it's the workflow below, ready to use as-is: ```yaml name: Deploy to GitHub Pages on: push: branches: [main] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: pages cancel-in-progress: true jobs: build: name: Build site runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.0 - run: bun install --frozen-lockfile - run: bunx laurel build env: GITHUB_PAGES: "true" - uses: actions/upload-pages-artifact@v3 with: path: dist deploy: name: Deploy to Pages needs: build runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment uses: actions/deploy-pages@v4 ``` Then in the repo settings: **Pages → Build and deployment → Source = GitHub Actions**. Push to `main` and the action publishes the site. If your site lives at `https://.github.io//` (project pages, not a custom domain or user site), set the deployed URL in `laurel.toml`: ```toml [site] url = "https://.github.io//" ``` In GitHub Actions, `GITHUB_PAGES=true` plus `GITHUB_REPOSITORY=/` lets Laurel derive `base_path = "//"` automatically so `{{asset}}`, `{{url}}`, and navigation links emit correct URLs for the subdirectory. Set `[build].base_path` manually only if you need to override that derived path. Laurel also writes `dist/.nojekyll` on every successful build so GitHub Pages serves underscore-prefixed assets and directories instead of running them through Jekyll. If `[deploy.github_pages].custom_domain` is set, the build writes `dist/CNAME` with that hostname for Pages custom-domain binding. The generated not-found page stays at `dist/404.html` for both user / organization sites and project sites. For a project site, `base_path = "//"` changes URLs in the HTML, but it does not nest the artifact under `dist//`; GitHub Pages serves the root `404.html` at `https://.github.io//404.html`. --- ## S3 + CloudFront **Recommended for:** teams already operating in AWS, private S3 origins, and CloudFront-managed TLS / caching. For the focused AWS guide, including OIDC setup, the CloudFront Function for directory-style URLs, CloudFront custom error responses for `404.html`, and `laurel deploy s3`, see [`docs/deploy/s3-cloudfront.md`](../deploy/s3-cloudfront.md). Copy [`examples/ci/s3-cloudfront.yml`](../../examples/ci/s3-cloudfront.yml) to `.github/workflows/s3-cloudfront.yml`, then set: | Type | Name | | --- | --- | | Secret | `AWS_ROLE_TO_ASSUME` | | Secret | `CLOUDFRONT_DISTRIBUTION_ID` | | Variable | `AWS_REGION` | | Variable | `S3_BUCKET` | The workflow builds with Bun, verifies `dist/.laurel-manifest.json`, syncs `dist/` to S3 with cache-control metadata, and invalidates CloudFront. Pair a private S3 origin with the CloudFront Function at [`examples/s3-cloudfront/append-index.js`](../../examples/s3-cloudfront/append-index.js) so `/about/` resolves to Laurel's generated `/about/index.html` object. If your site has `redirects.yaml`, S3 + CloudFront will not read the generated `dist/_redirects` file. Generate a CloudFront Function from that YAML instead: ```bash bun scripts/generate-cloudfront-redirects.ts \ --out cloudfront-redirects.generated.js ``` The output follows [`examples/deploy/s3-cloudfront/cloudfront-redirects.js`](../../examples/deploy/s3-cloudfront/cloudfront-redirects.js) and inlines an exact-URI redirect map for 301, 302, 307, and 308 responses. Publish the generated function on the viewer-request event before the request reaches the S3 origin. Also configure CloudFront custom error responses for both `403` and `404` origin errors. Point them at `/404.html` and keep the viewer response code as `404`. Private S3 origins can report a missing key as `403` or `404` depending on bucket permissions; mapping both errors gives visitors Laurel's generated not-found page without turning real misses into successful `200` responses. For local uploads after a successful build, configure: ```toml [deploy.s3] bucket = "my-blog-prod" region = "us-east-1" # delete = true ``` Then run: ```bash bunx laurel deploy s3 --build ``` The CLI syncs `dist/` to the bucket and forwards `--region` when configured. When `[build].precompress = "both"` has emitted `.br` / `.gz` sidecars, it uploads those objects with `Content-Encoding: br` / `gzip` metadata so CloudFront can serve origin-compressed assets correctly. It does not create CloudFront invalidations or apply the workflow's split cache-control metadata; keep using the workflow for the full production S3 + CloudFront path. --- ## Bunny.net **Recommended for:** Bunny CDN users who want to host the complete static `dist/` output in Bunny Storage and deliver it through a Pull Zone. For the focused Bunny.net guide, including the current no-emitter/no `laurel deploy bunny` status, see [`docs/deploy/bunny.md`](../deploy/bunny.md). The minimal flow is: 1. Build locally: ```bash bunx laurel build test -f dist/.laurel-manifest.json ``` 2. In Bunny, create a Storage Zone, then create or connect a Pull Zone with **Origin Type = Storage Zone**. 3. Upload the contents of `dist/` to the root of the Storage Zone using the dashboard, Bunny's HTTP Storage API, FTP, or a storage-sync tool that matches your CI policy. 4. Serve and verify the site through the Pull Zone hostname, not the Storage API endpoint: ```bash curl -sI https://my-blog.b-cdn.net/ | sort curl -sI https://my-blog.b-cdn.net/about/ | sort curl -sI https://my-blog.b-cdn.net/404.html | sort ``` Bunny does not consume Laurel's `_headers`, `_redirects`, `vercel.json`, or `dist/.laurel/nginx.conf` files. Configure cache headers, security headers, redirects, custom hostnames, SSL, stale-file cleanup, and CDN purges in Bunny or your deploy pipeline. If you need redirect fallbacks without Bunny Edge Rules, `[components.redirects].emit_html = true` can emit browser-level fallback pages, but those responses are still `200`. --- ## nginx **Recommended for:** self-hosted VPS deployments and Ghost migrations already running behind nginx. For the focused nginx guide, including TLS notes and troubleshooting, see [`docs/deploy/nginx.md`](../deploy/nginx.md). 1. Enable the nginx deploy target and set the filesystem root nginx will serve: ```toml [deploy.nginx] enabled = true root = "/var/www/laurel" server_name = "example.com" ``` 2. Build locally and confirm the generated config exists: ```bash bunx laurel build test -f dist/.laurel/nginx.conf ``` 3. Sync the complete `dist/` directory to the server: ```bash rsync -avz --delete dist/ user@host:/var/www/laurel/ ``` 4. Include the generated server block from nginx's main config, under the top-level `http { ... }` context: ```nginx include /var/www/laurel/.laurel/nginx.conf; ``` 5. Test and reload nginx on the server: ```bash sudo nginx -t sudo systemctl reload nginx ``` The generated file folds `[deploy.headers]` and `redirects.yaml` into a full `server { ... }` block at `dist/.laurel/nginx.conf`. It sets `Cache-Control` for Laurel's default asset paths, repeats security headers inside each `location`, enables `gzip_static` and `brotli_static`, serves `slug/index.html` URLs with `try_files $uri $uri/ $uri/index.html =404;`, and uses `dist/404.html` as the nginx 404 response body before turning redirect rules into nginx `return` directives. --- ## Troubleshooting deploys - **Build runs locally, fails in CI.** Usually a missing Bun. Confirm the host installed Bun ≥ 1.3 (Cloudflare/Netlify need `BUN_VERSION` env; Render needs `BUN_VERSION`; Vercel auto-detects from `bun.lock`; GitHub Actions needs `setup-bun@v2`). - **404 on direct page loads in production.** Your host is stripping trailing slashes. Add a redirect rule (`netlify.toml`, `vercel.json`, `_redirects`) or configure "Always append trailing slash" in the host's settings. On nginx, confirm the generated `try_files $uri $uri/ $uri/index.html =404;` block is the one handling the request. - **`nginx -t` fails on `brotli_static`.** Your nginx build does not have the Brotli module loaded. Install an nginx package with Brotli support, load the module, or remove `brotli_static on;` from the deployed include. - **Assets 404 with `//...` prefix on GitHub Pages.** Confirm the build ran with `GITHUB_PAGES=true` and `GITHUB_REPOSITORY=/`, or set `[build] base_path` manually to your subdirectory path with leading and trailing slash, e.g. `"/my-blog/"`, and rebuild. - **GitHub Pages ignores your project-site 404 page.** Keep the generated file at `dist/404.html`. Do not move it to `dist//404.html`; the repo name belongs in `[build].base_path`, not in the artifact layout. - **S3 + CloudFront returns 403 or 404 for nested pages or missing URLs.** CloudFront's default root object only covers `/`. Attach the CloudFront Function from `examples/s3-cloudfront/append-index.js` so directory-style URLs request each page's generated `index.html`, and configure custom error responses for `403` and `404` so true misses serve `/404.html` with viewer status `404`. - **S3 + CloudFront ignores redirects.yaml.** Generate the CloudFront Function sample from `examples/deploy/s3-cloudfront/cloudfront-redirects.js` with `scripts/generate-cloudfront-redirects.ts`, then publish the generated function on the viewer-request event. - **Render deploys but redirects or headers do not apply.** Render Static Sites do not consume Laurel's generated `_redirects` / `_headers` as a platform contract. Configure those rules in the Render dashboard until Laurel has a Render-specific emitter. - **Site builds but RSS / sitemap missing.** Check `laurel.toml` — those are optional components; they default to enabled but can be turned off: ```toml [components.rss] enabled = true ``` For preview deploys against a subpath, build with `--base-path`: ```bash bunx laurel build --base-path /preview/feature-x/ ``` ## Security headers The configs above get the site live, but most hosted platforms still need a stricter security header baseline. Static hosts default to no CSP, no HSTS, no Referrer-Policy on most free tiers — fine for a personal site, risky once you accept contributions to `content/`, enable `build.allow_code_injection`, or serve a custom domain. See [`docs/security/hosting.md`](../security/hosting.md) for copy-pasteable `_headers` / `vercel.json` / `netlify.toml` snippets with a Laurel-calibrated baseline `Content-Security-Policy`, `Strict-Transport-Security`, `Referrer-Policy`, `Permissions-Policy`, and related headers. nginx users should set the same values under `[deploy.headers].security` so they are emitted into `dist/.laurel/nginx.conf`. GitHub Pages users will find the workarounds for the host's hard-coded headers there too.