Skip to content
BuildStackHQHosting
All docs

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 key hostname) inside the same transaction that inserts the site or the ingress binding. A collision surfaces as 409 hostname_taken on either side. Archiving a site or revoking an ingress binding releases the claim.
  • A SiteDeployment is uploading while the archive streams in, then ready (with fileCount, totalBytes and a manifestHash) or failed (with a bounded failureReason). Files are stored under sites/<organization>/<site>/<deployment>/<path>; a deployment never changes after it is ready.
  • Settings, all validated in the domain (validateSiteSettings): spaFallback (serve index.html for 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 and X-Content-Type-Options are reserved and refused, values may not contain control characters) and redirects (at most 200 rules { from, to, status } with status 301, 302, 307 or 308; from may end in /* and to may contain :splat). A redirect target is either a site path or an absolute http(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-retention worker 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 refreshes site_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 pass maxSites.

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):

  1. Redirect rules are matched against the request path as sent (percent encoding intact, so the Location stays a valid header value); the query string is carried over.
  2. The path is percent decoded and resolves to candidates in order: the exact file, <path>/index.html, <path>.html; / is index.html, a trailing slash means the directory index. Traversal (also encoded as %2e%2e or ..%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.
  3. When nothing matches: index.html with status 200 when spaFallback is on and the last segment has no extension (an asset such as /assets/app.js is never answered with HTML), otherwise the notFoundPath file with status 404, otherwise 404 not_found as application/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 under sites/<organization>/<site>/<deployment>/<path>, encrypted with the platform key (aws:kms with SITE_STORE_KMS_KEY_ARN, AES256 when 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 needs s3:GetObject on sites/* and s3:ListBucket; the API and the site-retention worker also need s3:PutObject and s3: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.