How it works
flypod has two planes. A deploy pipeline ingests a zip into an immutable version, and a serve plane routes a subdomain to that version's files.
flypod is two planes. The deploy pipeline turns an uploaded zip into an immutable version and points a site at it. The serve plane maps an incoming request's subdomain to that site and returns its files. Everything else (versioning, TTL, ownership) hangs off these two.
The deploy pipeline
A deploy is a single POST /sites with a zip body (or a JSON map of file
names to text). Before anything is read, the request passes the per-IP rate
limit and, where one is configured, a bot challenge. flypod then processes it in
order:
Ingest the zip. Files are unpacked under size and count caps (on flypod.dev: 200 files, 10 MB of files, a 12 MB upload), with a path-traversal guard so no entry can escape the site root. An empty upload is refused.
Render Markdown (if needed). If the upload has no index.html but does
contain Markdown files, flypod renders them into a small styled site.
Fill in a missing index.html. If there is no root index.html but
exactly one HTML file, that file is also served as the index, so a lone page
works at /. Otherwise a missing index is reported as a missing-index
warning.
Detect SPA. flypod inspects the site to decide whether it's a single-page app. This flag drives the serve-plane fallback (below).
Abuse scan. Contents are checked against abuse rules before anything is
stored. A hard match refuses the deploy (403); a softer one publishes it with
a shorter expiry.
Store an immutable version. The processed content is hashed and stored as a version that never changes. Every update is scanned the same way before it is stored.
Create or point the site. A new site is created (a folder's first deploy,
or --new) or an existing one's live pointer is moved (flypod update, or
flypod again in the same folder) to serve that version.
The response carries the live URL, the credentials
(tokens), the
expiry, anything flypod repaired during ingest
(healed), and any warnings, each carrying a fix_recipe a caller can act
on. It also carries a render key, which on flypod.dev is always
{"status": "pending"} — the hosted service runs no headless browser, so there
is no screenshot and no console capture. See
the HTTP API for the full response shape.
The serve plane
Each site lives at https://<site_id>.flypod.page. The subdomain selects the
site. That's the entire routing model. A request to / serves the site's
/index.html.
How a path becomes a file
flypod resolves a request the way a static export expects:
| Request | Served |
|---|---|
/about.html | about.html |
/about | about.html (clean URL) |
/blog/ | blog/index.html |
/blog | 301 to /blog/, when the file is blog/index.html |
/about/ | 301 to /about, when the file is about.html |
| anything else | the site's own 404.html if it ships one, with a 404 status |
The redirect, rather than serving the index in place, keeps relative URLs in
the page working. A site's 404.html is used for missing pages; a missing
asset such as /app.js gets a short plain 404, so a script never receives
HTML.
Two domains, one rule
Deployed sites are served on flypod.page. The API, the docs, the marketing
site, the account pages, and flypod's email all live on flypod.dev, which
never serves uploaded content.
The rule has no exceptions: every site, anonymous or claimed, answers on
<site_id>.flypod.page. Claiming a site removes its expiry; it does not move
the site, so the URL you were handed is the URL you keep. The split is a design
decision, not a caveat — a deploy requires no account, so keeping all uploaded
content on a separate domain means one bad upload can damage the reputation of
flypod.page only, and never the domain the API and docs depend on.
Whether a site is indexable is a separate question from which host serves it, and still follows ownership — see SEO and indexing.
Site URLs issued before 2026-09-14 used <site_id>.flypod.dev. Those 301 to
the same id on flypod.page until 2026-12-13, after which they 404.
Responses are cacheable and revalidate cheaply:
ETag: W/"<live_version_id>:<path>"
cache-control: public, max-age=60, must-revalidateThe ETag is the live version id plus the path, so a request that matches gets a
304 Not Modified. Because the version id changes whenever the content does, a
new version automatically invalidates old cache entries. The tag is weak
(W/) for text, HTML, CSS, JavaScript, JSON and SVG, which the edge compresses,
and strong for everything else (images, video, fonts), which byte ranges need.
Files whose name carries a build hash are different. app.3f9a2c1b.js or
assets/index-BjK3x9aZ.css can never change under that name, so they are
served to be kept for a year:
cache-control: public, max-age=31536000, immutableThe same applies to anything under a framework's content-addressed directory
(_next/static/, _astro/, _app/immutable/, _nuxt/, _expo/static/,
_build/). HTML is never cached this way, since it is what names the hashed
files. A not-found page is sent with cache-control: no-cache, so it never
outlives the deploy that adds the page.
Non-HTML files also answer byte-range requests (206 Partial Content), which
is what lets <video> and <audio> play on Safari and iOS.
SPA fallback
If a request path has no file extension and no file matches, flypod falls
back to serving index.html, but only if the live version was detected as a
single-page app. This lets client-side routers handle /about without a
real about.html. Static sites get a normal 404 for the same request.
Status codes
| Status | When |
|---|---|
200 | File found and served |
206 | A byte range of a non-HTML file (Range) |
301 | A trailing-slash redirect, see above, or a legacy flypod.dev site URL |
304 | ETag matched (If-None-Match) |
400 | A malformed path (bad percent-encoding, or a .. segment) |
404 | Unknown host or site, or a missing file on a non-SPA site |
405 | Any method other than GET or HEAD: a site is read-only |
410 Gone | The site expired |
416 | A byte range outside the file |
451 | The site was disabled by an operator |
SEO and indexing
flypod injects invisible attribution into served HTML: meta tags and JSON-LD,
no visible badge. Anonymous sites (any site with an expiry) are additionally
served with x-robots-tag: noindex, keeping ephemeral deploys out of search
engines. So are not-found pages and a version's own
timeline address.
It also fills in the link-preview tags a page lacks. Where a page has no
og:title, flypod adds one from its <title> (or first <h1>); where it has
no og:description, one from its meta description or first paragraph. When it
adds either, it also adds og:type and twitter:card if they are missing, so
the card actually shows. A pasted URL then unfurls with the page's own title
and summary instead of a bare address. Tags you wrote are never changed, no
title or description is made up, and <meta name="flypod" content="none">
turns all of it off.
llms.txt
Every site answers /llms.txt, the file an AI agent asks for to learn what a
site contains without crawling it. If the deploy ships its own, that file is
served unchanged. Otherwise flypod generates one from the site's HTML pages
and their titles (up to 200 pages listed, titles read for the first 25). It
always describes the live version, including straight after an update or a
rollback (it is served cache-control: no-cache), and it adds nothing to a
deploy: it is built the first time it is requested.
Dead links
A browser that opens an address with no site gets a short page explaining
that preview links last 14 days and how the owner keeps one. Expired sites are
removed within minutes of expiring, so this is what an old temporary link
shows. Anything that does not ask for HTML still gets the plain Not found.
HTTP API
The flypod REST API at https://flypod.dev deploys sites from a zip, redeploys new versions, rolls back, and manages accounts with bearer-token auth.
Versioning
Every deploy is an immutable version identified by a hash of its content; a single live pointer selects which version a site serves, moved by update and rollback.