Docs
Static sites
Deploying a build directory, immutable deployments and rolling back.
keel sites deploy ./dist publishes a static frontend (Vite, Astro, a Next static export, plain HTML) on https://<slug>-<shortOrg>.svc.<platform domain> in a few seconds. Files live in EU object storage and are served by the ingress relay (apps/ingress); there is no container per site. Every deployment is immutable, switching the live deployment is one row update, and rolling back to the previous one is instant.
Sites are part of the Business and Enterprise plans at no extra price (features.sites). Starter has none. Per organization the plan caps the number of live sites (limits.maxSites: 10 on Business, unlimited on Enterprise), and per deployment the size (limits.siteMaxBytes, 250 MB uncompressed) and the file count (limits.siteMaxFiles, 10,000). The bytes of every ready deployment are recorded per organization in site_usage.site_bytes so a storage price can be attached later.
Ten second tour
keel init --template static # keel.yaml with a site: section and a public/ directory
keel sites deploy # creates the site when missing, uploads public/, activates it
keel sites open # https://<slug>-<shortOrg>.svc.<domain>
keel sites rollback # the previous deployment is live again
keel.yaml carries the site section; both keys are optional and keel sites deploy [dir] --site <slug> overrides them:
name: Marketing site
slug: marketing
site:
slug: marketing
dir: public
Vite users set dir: dist, Astro dir: dist, a Next static export dir: out. Run your build first; the CLI refuses a directory without an index.html at its root.
The model
- A
Site(packages/domain/src/sites) has a slug, a name, a hostname, the ids of its current and previous deployment, a lifecycle (live,archived) and serving settings. The hostname is allocated with the ingress label rules (ingressHostnameFor:<slug>-<first eight hex of the organization id>.svc.<domain>, label capped at 63 characters), so a site and a service never differ in shape. - A site and a service cannot collide on a hostname: both claim their names in
hostname_claims(primary keyhostname) inside the same transaction that inserts the site or the ingress binding. A collision surfaces as409 hostname_takenon either side. Archiving a site or revoking an ingress binding releases the claim. - A
SiteDeploymentisuploadingwhile the archive streams in, thenready(withfileCount,totalBytesand amanifestHash) orfailed(with a boundedfailureReason). Files are stored undersites/<organization>/<site>/<deployment>/<path>; a deployment never changes after it is ready. - Settings, all validated in the domain (
validateSiteSettings):spaFallback(serveindex.htmlfor any path without a file),notFoundPath(a file served with status 404, for example/404.html),headers(at most 20 response headers; content, caching, cookie, hop by hop headers andX-Content-Type-Optionsare reserved and refused, values may not contain control characters) andredirects(at most 200 rules{ from, to, status }with status 301, 302, 307 or 308;frommay end in/*andtomay contain:splat). A redirect target is either a site path or an absolutehttp(s)://URL: the site owner chooses where their own hostname redirects, so external targets are intended and are the only way a site can send a visitor to another host. - Retention: the
site-retentionworker role keeps the newest five ready deployments per site plus whatever is current or previous, deletes failed deployments and uploads abandoned for more than an hour, deletes every deployment of an archived site, and refreshessite_usage. It runs every ten minutes. The row is removed before the objects, and the delete locks the site row and refuses a deployment that a live site points at, so a deployment activated while the sweep runs survives it. - Quota: creating a site takes
pg_advisory_xact_lock(hashtext('sites.quota:<organization>'))before counting live sites, so two concurrent creates cannot both passmaxSites.
Uploading
The CLI walks the directory (scanDirectory from @keel/runtime-contract/archive), skips dotfiles and symlinks, refuses the upload when the plan caps would be exceeded, hashes every file with sha256 and streams a tar.gz as the body of POST /v1/sites/{id}/deployments with Content-Type: application/gzip. The API accepts up to SITE_MAX_UPLOAD_BYTES compressed (250 MB by default) and at most four times that decompressed, 50000 tar entries and 1 MiB per long name or pax record, and never buffers the body: the stream is capped while it is read, unpacked entry by entry (siteEntriesFromTarGz, symlinks, hard links and directories are skipped), every path normalized and validated (no traversal, no absolute paths, no backslashes, no control characters, at most 1024 characters and 64 segments), the content type set from the extension and streams each file into the SiteStore while counting bytes against the plan. A file whose bytes differ from the tar header is refused. When the stream ends the deployment becomes ready with a manifest hash over path sha256 size lines; the CLI computes the same hash locally and refuses to activate a deployment whose stored files differ from what it sent.
Anything that goes wrong marks the deployment failed with the reason, removes the stored files and answers a problem: 400 invalid_file_path, 400 malformed_archive, 400 empty_deployment, 413 too_many_files, 413 deployment_too_large, 413 site_archive_too_large, 415 site_archive_unsupported. Failed deployments stay in the history until retention removes them.
Serving
The relay resolves the Host against ingress bindings first and, when no service answers to the name, against live sites (SiteResolver, five second cache, same as ingress). A site without a live deployment answers 404 no_deployment. For GET and HEAD (anything else is 405 method_not_allowed):
- Redirect rules are matched against the request path as sent (percent encoding intact, so the
Locationstays a valid header value); the query string is carried over. - The path is percent decoded and resolves to candidates in order: the exact file,
<path>/index.html,<path>.html;/isindex.html, a trailing slash means the directory index. Traversal (also encoded as%2e%2eor..%2f), dotfiles, NUL bytes, undecodable paths and any character no stored path can contain (backslash, space, non ASCII) fall through to step 3 without touching the store. - When nothing matches:
index.htmlwith status 200 whenspaFallbackis on and the last segment has no extension (an asset such as/assets/app.jsis never answered with HTML), otherwise thenotFoundPathfile with status 404, otherwise404 not_foundasapplication/problem+json.
Every file is served with its content type, a strong ETag (the sha256 of the object on the local store, the S3 ETag on S3; If-None-Match answers 304), Cache-Control: public, max-age=31536000, immutable for hashed assets (a . or - followed by at least eight hex characters before the extension, such as app-3f9a1b2c.js), no-cache for HTML and public, max-age=0, must-revalidate for everything else, Referrer-Policy: strict-origin-when-cross-origin, the site's own headers and, always last, X-Content-Type-Options: nosniff. Text like content up to the cache object size is compressed with brotli or gzip according to Accept-Encoding (Vary: accept-encoding); the compressed bytes are cached next to the original. The relay keeps an in memory LRU of hot objects (INGRESS_SITE_CACHE_BYTES, 64 MB, objects up to INGRESS_SITE_CACHE_OBJECT_BYTES, 2 MB); larger files stream straight from the store. Deployments are immutable and the cache key is <site>/<deployment>/<path>, so a cache entry is never stale and a rollback serves the old bytes with their own ETag; a switch is visible after the resolver cache expires (five seconds).
The per hostname rate limit of the relay applies to sites too (INGRESS_RATE_LIMIT_PER_MINUTE).
API
| Endpoint | Status | Permission | Notes |
|---|---|---|---|
GET /v1/sites |
200 | sites.read |
{ items, usage: { siteBytes, sites }, limits }; archived sites are not listed. limits is null when the plan cannot be resolved. |
POST /v1/sites |
201 | sites.write |
{ slug, name? }. 409 sites_not_in_plan, site_limit_reached with limit, site_slug_taken, hostname_taken; 501 not_configured without INGRESS_PUBLIC_DOMAIN. |
GET /v1/sites/{id} |
200 | sites.read |
The site plus currentDeployment and deployments (newest first, each with current and previous). |
PATCH /v1/sites/{id} |
200 | sites.write |
{ name?, settings? }, settings as a partial patch. 400 invalid_site_settings carries the domain error (reserved_header, too_many_redirects, ...). |
DELETE /v1/sites/{id} |
204 | sites.write |
Archives the site: the hostname stops resolving at once, files go with the next retention sweep, the slug is free again. |
GET /v1/sites/{id}/deployments |
200 | sites.read |
{ items }. |
POST /v1/sites/{id}/deployments |
201 | sites.write |
Body is the tar.gz, Content-Type: application/gzip. Answers the ready deployment. |
POST /v1/sites/{id}/deployments/{deploymentId}/activate |
200 | sites.write |
Atomic swap of currentDeploymentId; the old current becomes previousDeploymentId. 409 deployment_not_ready, 404 deployment_not_found, 409 site_changed when the pointers moved underneath (retry). |
POST /v1/sites/{id}/rollback |
200 | sites.write |
Swaps current and previous. 409 nothing_to_roll_back, 409 previous_deployment_missing when the previous deployment was pruned. |
API key scopes: sites:read grants sites.read; sites:write grants sites.write and sites.read. viewer members have sites.read, every other role has both. Every mutation lands in the audit log like the rest of the API.
CLI
| Command | What it does |
|---|---|
keel sites create <slug> |
Creates the site and prints the URL. |
keel sites deploy [dir] [--site <slug>] [--no-activate] |
Uploads dir (default site.dir from keel.yaml) to the site (default site.slug, then slug), creating the site when it does not exist, verifies the manifest and activates the deployment unless --no-activate. Exit 2 for a missing directory, a directory without index.html, or a directory over the plan caps. |
keel sites list |
Table of sites with URL, live deployment and usage. |
keel sites show [site] |
Site details and the deployment history. |
keel sites activate <site> <deployment> |
Makes a ready deployment live. |
keel sites rollback [site] |
Serves the previous deployment again. |
keel sites open [site] [--print] |
Opens the public URL in the browser, or prints it. |
keel sites archive [site] |
Archives the site. |
keel init --template static scaffolds keel.yaml, public/index.html, public/about.html, public/404.html and public/styles.css.
A site takes a custom domain with keel domains add www.customer.com --site <slug> (POST /v1/sites/{id}/domains); verification, certificates and the statuses are the ones in Custom domains, with the CNAME pointing at the site's platform hostname.
Dashboard
/sites lists the sites of the organization with usage against the plan and lets writers create one. /sites/{id} shows the public URL with a copy button, the CLI deploy command, the deployment history with Activate per ready deployment, Roll back, Archive site (each behind a confirmation dialog), a Domains section with the same add form, DNS records, verify and remove actions as a service agent's page, and the serving settings form (name, not found page, SPA fallback, headers and redirects as one rule per line). Everything is keyboard reachable and works at phone width; the Playwright journey is apps/dashboard/test/e2e/sites.spec.ts.
Storage and deployment
SiteStore (packages/application/src/sites/ports.ts) has two adapters:
S3SiteStore: one prefix per organization, objects undersites/<organization>/<site>/<deployment>/<path>, encrypted with the platform key (aws:kmswithSITE_STORE_KMS_KEY_ARN,AES256when no key is configured). Site files are public content, so the tenant key is not used; the bucket still refuses unencrypted writes and public access. The relay task needss3:GetObjectonsites/*ands3:ListBucket; the API and thesite-retentionworker also needs3:PutObjectands3:DeleteObject.LocalSiteStore: a directory tree for the single host without S3 and for tests. The API, the relay and the retention worker must share the directory.
| Variable | Default | Purpose |
|---|---|---|
SITE_STORE |
local |
local or s3; read by the API, the worker and the relay. |
SITE_STORE_DIR |
.keel/sites |
Root of the local store. |
SITE_STORE_BUCKET |
PAYLOAD_STORE_BUCKET on the API and worker; required on the relay |
Bucket for SITE_STORE=s3. |
SITE_STORE_KMS_KEY_ARN |
PAYLOAD_STORE_KMS_KEY_ARN on the API and worker; none on the relay |
Platform key for new objects. |
SITE_STORE_PREFIX |
sites |
Key prefix. |
SITE_MAX_UPLOAD_BYTES |
262144000 | Compressed upload cap on the API. |
INGRESS_SITE_CACHE_BYTES, INGRESS_SITE_CACHE_OBJECT_BYTES |
64 MB, 2 MB | Relay object cache. |
INGRESS_PUBLIC_DOMAIN on the API decides the site hostnames, exactly as for services.
Single host (deploy/single-host): the api, ingress and site-retention containers run with SITE_STORE=s3 against the payload bucket (SITE_STORE_BUCKET and SITE_STORE_KMS_KEY_ARN default to the payload values from .env), under the instance role that already grants the bucket. Set SITE_STORE=local in .env to keep sites on the keel-sites volume instead; the volume is mounted into all three containers at /var/lib/keel/sites.
Fargate (infra/terraform): the s3 module adds the keel-sites-<env> bucket (same encryption and policy as the other buckets), the ingress module grants the relay task read access and sets SITE_STORE, SITE_STORE_BUCKET and SITE_STORE_KMS_KEY_ARN, and the API service receives the same variables; the API task role already covers every platform bucket. The site-retention worker takes the API environment plus the role name, like the other worker roles.
Migration
0024_sites creates hostname_claims, sites, site_deployments and site_usage with tenant RLS on organization_id (the relay reads sites and site_deployments through keel_platform), backfills claims for every existing ingress hostname and alias, and adds features.sites, limits.maxSites, limits.siteMaxBytes and limits.siteMaxFiles to the stored plans.