Skip to content

Self-hosting on any static host

ui.plan.ai is a fully static site. pnpm build emits a self-contained dist/ directory; any host that serves static files can serve it. This page is the generic-host counterpart to Cloudflare Pages configuration.

A single dist/ tree:

  • Main app at / (dist/index.html, dist/_astro/*, …)
  • Starlight docs at /docs/ (dist/docs/index.html, dist/docs/_astro/*, dist/docs/pagefind/*, dist/docs/sitemap-index.xml)
  • Header policy at dist/_headers (Cloudflare/Netlify format)
  • Redirect rules at dist/_redirects (Cloudflare/Netlify format)
  • dist/robots.txt, dist/favicon.svg, dist/favicon.ico

No SSR, no Workers, no Edge Functions, no environment variables.

SettingValue
Build commandpnpm build
Output / publish directorydist
Root directory/
Node versionfrom .node-version (24.15.0)
Package managerpnpm via Corepack (already pinned in packageManager)
Environment variablesnone

If the host doesn’t auto-read .node-version, set Node 24.15.0 (or newer) explicitly. If it doesn’t auto-enable Corepack, prepend the build command with corepack enable && corepack prepare [email protected] --activate.

Both Astro configs use trailingSlash: 'always' + build.format: 'directory'. The build emits page/index.html, so the URL space is directory-style.

  • Requests for /foo/ must serve /foo/index.html (every static host does this by default).
  • Requests for /foo (no slash) ideally 301 to /foo/. Cloudflare Pages, Netlify, and Vercel do this automatically; for raw Nginx, add try_files $uri $uri/ =404; or rewrite to add the slash.

public/_redirects (lands in dist/_redirects after build) holds the only required redirect:

/docs /docs/start-here/welcome/ 301
/docs/ /docs/start-here/welcome/ 301

Translation per host:

  • Cloudflare Pages / Netlify — read _redirects natively. Nothing to do.
  • Vercel — add to vercel.json:
    {
    "redirects": [
    { "source": "/docs", "destination": "/docs/start-here/welcome/", "permanent": true },
    { "source": "/docs/", "destination": "/docs/start-here/welcome/", "permanent": true }
    ]
    }
  • Nginx — in the relevant server { } block:
    location = /docs { return 301 /docs/start-here/welcome/; }
    location = /docs/ { return 301 /docs/start-here/welcome/; }
  • Apache / .htaccess:
    RedirectMatch 301 ^/docs/?$ /docs/start-here/welcome/
  • CloudFront / S3 — use a CloudFront Function on viewer-request, or a redirect rule on the bucket.
  • GitHub Pages — no native server-side redirects. The Starlight build also emits a meta-refresh HTML fallback at dist/docs/index.html, so the redirect still works (with a brief flash) even when _redirects is ignored.

public/_headers (lands in dist/_headers) holds the security and caching baseline:

  • Global /*Referrer-Policy: strict-origin-when-cross-origin, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, deny-all Permissions-Policy for sensors and payments, Cross-Origin-Opener-Policy: same-origin. No CSP yet (Starlight inlines styles/scripts).
  • Long-lived caches on content-hashed paths — /_astro/*, /docs/_astro/*, /docs/pagefind/*public, max-age=31536000, immutable.
  • Favicons — 1-day cache with must-revalidate.

Per host:

  • Cloudflare Pages / Netlify — read _headers natively. Nothing to do.
  • Vercel — port to the headers array in vercel.json.
  • Nginx / Apache / CloudFront — port to the host’s native header/CDN config. Treat public/_headers as the source of truth and keep your host config in sync.

The headers are a hardening baseline, not a requirement — the site renders fine without them.

If you serve from anything other than https://ui.plan.ai, update site: in both astro.config.mjs and starlight/astro.config.mjs and rebuild. This drives sitemap absolute URLs and Starlight’s canonical <link> tags. Also update public/robots.txt (which references the sitemap URL).

The docs site uses Starlight’s built-in Pagefind search. The index is generated at build time into dist/docs/pagefind/ and is fully static — no server-side search infrastructure needed.

  • Starlight emits dist/docs/sitemap-index.xml (because base: '/docs').
  • dist/robots.txt references that sitemap. Submit to Search Console at the sitemap URL after deploy.

There is no root sitemap. If the main app gains real content, add one and extend robots.txt with a second Sitemap: line.

build:docs ran but public/docs/ wasn’t included in the upload. Re-run pnpm build locally and confirm dist/docs/index.html exists. Most common cause: using npm or yarn instead of pnpm — workspaces only resolve under pnpm here.

/docs (no slash) doesn’t redirect to the welcome page

Section titled “/docs (no slash) doesn’t redirect to the welcome page”

Either the host isn’t honoring _redirects or it strips trailing slashes before applying redirects. Add the rule in the host’s native format (see Redirects). The Starlight meta-refresh at dist/docs/index.html is a fallback, not a primary mechanism.

Section titled “Internal links 404 in production but work locally”

site: in one of the astro.config.mjs files doesn’t match the served origin, or your host is stripping trailing slashes. Fix site: and ensure the host serves page/index.html for both /page and /page/.

The build output wasn’t uploaded in full. Re-upload dist/ recursively. Watch for hosts that skip dotfiles (_astro/, _headers, _redirects start with _ but are real files — not hidden).

Your host doesn’t read _headers. Port the rules to the host’s native config. Verify with curl -I https://your-domain/ after deploy.

/_astro/* and /docs/_astro/* are content-hashed and immutable for one year. Hard-refresh the browser; if the HTML still references old hashes, the deploy didn’t pick up the new build output.