# Sites

Source: https://dabadata.com/docs/sites/ (HTML) - https://dabadata.com/docs/sites/index.md (this page as markdown)

dabadata Sites host your app's frontend next to its data. A site is a folder of static files - a plain HTML page, or the build output of Vite, React, Vue, Svelte or Solid - served from the edge at `<name>.dabadata.site` on every plan, and at your own domain from Starter. Deploy it with one command or let your AI agent put it live; the page talks to your project with its publishable key, and its address is already an allowed origin.

## What a site is

- A site belongs to one environment of a project, live or test, and counts toward its workspace's plan. Deleting the project deletes its sites, their domains and their files.
- Every site gets `https://<name>.dabadata.site`. From Starter you can connect your own domain, such as `www.example.com` ([Your own domain](https://dabadata.com/docs/sites/index.md#domains)).
- The files are served by a worker at the edge, straight from object storage; your API server is not involved, so a busy site never slows your API down. Serving is free and does not count toward egress or requests (fair use: a site whose traffic is abusive may be suspended).
- `dabadata.site` is a domain of its own, apart from the service's domain, and on the Public Suffix List: one site cannot set cookies for another site, the console or the API.

## Create a site

In the console, open the project's **Sites** page and create one there; it says as you type whether the name is free. From a terminal, with the [`dabadata` CLI](https://dabadata.com/docs/getting-started/index.md#cli) signed in and pointed at the environment:

```bash
dabadata use prj_xxxxxxxxxxxx:live     # the environment the site belongs to
dabadata sites create my-shop --spa    # https://my-shop.dabadata.site
dabadata sites list
```

- **Names** are 3 to 40 characters of `a-z`, `0-9` and `-`, not starting or ending with `-`, without `--`. A name is the site's address and cannot change later (delete the site and create another). A deleted site's name is free again after 30 days.
- Some names are reserved (409 `name_taken`): `www`, `api`, `app`, `admin`, `console`, `docs`, `mail`, `status`, `sites`, `dabadata`, and any name containing `login`, `signin`, `account`, `verify`, `secure`, `bank`, `paypal` or `wallet`.
- On Free, creating a site needs a confirmed email address (403 `email_unverified` otherwise).
- `--spa` makes it a [single-page app](https://dabadata.com/docs/sites/index.md#spa); you can switch it later.

A new site answers "nothing deployed yet" until its first deploy.

## Deploy with the CLI

Build your app, then deploy the output folder:

```bash
npm run build
dabadata sites deploy ./dist --site my-shop
# uploads the folder, publishes it, checks that https://my-shop.dabadata.site serves it
# Live: https://my-shop.dabadata.site (deploy dep_..., 12 files, 240 KB)
# Reachable: checked from our server at 10:00:03 UTC; every location has it by 10:02:00 UTC.
#   ok   my-shop.dabadata.site
```

- A deploy is the **whole** site: the folder as it is now. Nothing is merged with the deploy before, so a file you deleted locally is gone from the site too.
- Dot files and dot folders are skipped, except `.well-known/`.
- The files go straight to object storage, 8 at a time, each with a short-lived signed URL; then the deploy is published in one step.
- After publishing, the CLI waits (up to a minute) for the [readiness check](https://dabadata.com/docs/sites/index.md#readiness) and prints what each public hostname serves. `--no-wait` skips the wait.
- `--yes` skips the confirmation, for scripts and CI.

Deploying needs the environment's secret key or a console sign-in with the developer role or above; viewers can look but not deploy. The JavaScript client has no site calls, since deploys hold the secret key.

## Deploy from CI

A CI job deploys with the environment's [secret key](https://dabadata.com/docs/projects/index.md#keys), kept as a CI secret. [`deploy-site.mjs`](https://dabadata.com/ci/deploy-site.mjs) does the same steps as `dabadata sites deploy` over the [HTTP API](https://dabadata.com/docs/sites/index.md#http): it uploads the folder, publishes it, waits for the [readiness check](https://dabadata.com/docs/sites/index.md#readiness), and lists every file that did not upload, with the storage's answer. It needs Node 18 or later (or Bun, or Deno) and nothing else. Commit a copy to your repository, for example as `scripts/deploy-site.mjs`, so the code that holds your key is code you have read.

```yaml
# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test
      - run: npm run build
      - run: node scripts/deploy-site.mjs ./dist my-shop
        env:
          DABADATA_SECRET_KEY: ${{ secrets.DABADATA_SECRET_KEY }}
```

- **Exit code.** 0 when the deploy is live, 1 when nothing was published (a failed upload leaves the live site as it was), 2 for a usage error. The readiness result is printed; the deploy is live either way.
- **Which key.** Use the key of the environment the site belongs to: the live key for the production site. The key can do everything in its environment, so keep it in the CI's secrets only. If it leaks, [revoke it at once](https://dabadata.com/docs/projects/index.md#revoke).
- **Staging and previews.** There are no preview addresses per branch yet. Use a second site for staging, for example `my-shop-staging` in the test environment, deployed with the test secret key from another branch or workflow.
- **The CLI too.** `dabadata sites deploy ./dist --site my-shop --yes` takes the secret key as `DABADATA_TOKEN` and works out the environment from it.
- **Old deploys.** The newest 10 deploys and the live one are kept for [rollback](https://dabadata.com/docs/sites/index.md#rollback); older ones are deleted by the nightly job.

## Deploy from an AI agent

An agent connected to the project's [MCP server](https://dabadata.com/docs/mcp/index.md) with the secret key can build a page and put it live in one call. `site_deploy` takes the files themselves, uploads them on the server and publishes:

```json
{
  "site": "my-shop",
  "files": [
    { "path": "index.html", "content": "<!doctype html><html>...</html>" },
    { "path": "app.js", "content": "import { createClient } from ..." },
    { "path": "logo.png", "content": "iVBORw0KGgo...", "encoding": "base64" }
  ]
}
```

`encoding` is `"utf-8"` (the default) or `"base64"` for images and other binary files. One call carries at most 10 MB; a bigger site is deployed with the CLI. `site_deploy` and `site_rollback` wait up to 30 seconds for the [readiness check](https://dabadata.com/docs/sites/index.md#readiness) and say whether the site's hostnames serve the deploy. The other tools are `sites_list`, `site_create`, `site_domain_add` and `site_domain_check` ([MCP tools](https://dabadata.com/docs/mcp/index.md#hosted-tools)).

## Readiness: is it really live?

Publishing answers as soon as the deploy is live in the database. The edge locations pick a publish up within about 30 seconds, so for a few seconds some visitors can still get the previous deploy, or "No site here" on a first deploy. Readiness checks that the public hostnames actually serve the live deploy, so a script or an agent knows when to send people to the site.

Publishing, a rollback and a domain turning active start a check. It asks each public hostname - `<name>.dabadata.site` and every active domain - for one file of the deploy (`index.html`, or its first file), and checks four steps:

| Step | Passes when |
| --- | --- |
| `dns` | The hostname resolves to public addresses. |
| `tls` | The HTTPS connection succeeds with a valid certificate. |
| `route` | The site answers 200. A 404 means the edge has no route for the hostname yet; 503 means the site is paused or over its plan; 451 means it was taken down. |
| `deploy` | The answer names the live deploy (the `X-Dabadata-Deploy` header every site file carries); otherwise the hostname still serves an earlier deploy. |
| `certificate` | Your own domains only: the domain and its certificate are active at the edge provider. |

The site's `health` holds the result:

```json
"health": {
  "deploy": "dep_3f2a9c1e7b5d",
  "status": "reachable",
  "startedAt": "2026-10-18T10:00:00.000Z",
  "checkedAt": "2026-10-18T10:00:03.000Z",
  "reachableAt": "2026-10-18T10:00:03.000Z",
  "propagatedBy": "2026-10-18T10:02:00.000Z",
  "hosts": [
    { "hostname": "my-shop.dabadata.site", "ok": true,
      "dns": { "ok": true, "detail": "104.16.1.1" }, "tls": { "ok": true, "detail": null },
      "route": { "ok": true, "detail": null }, "deploy": { "ok": true, "detail": null } }
  ]
}
```

- `pending`: checking. The check runs at once, then every 5 seconds while a hostname is not ok, for at most 2 minutes.
- `reachable`: every hostname serves the live deploy. The check runs from our server, so it proves the location nearest to it; every location has the deploy by `propagatedBy` (2 minutes after the publish).
- `unreachable`: after 2 minutes some hostname still does not. Each host's failing step has a `detail` saying why, such as a DNS record that points elsewhere or a certificate that is not issued yet.
- `unverified`: after 2 minutes every hostname answers, but for some of them it could not be told which deploy they serve (their `deploy` step is `ok: null`). A site is only `reachable` when the deploy is proven.
- A step with `ok: null` was not reached because an earlier step failed, or could not tell.
- What a visitor's own network does (a company filter, a DNS block) cannot be seen from our server.

To wait in a script, add `?wait=` (seconds, at most 60) to publish or rollback: the answer comes once the check leaves `pending`, with `site.health`. To check again later, use the console's **Check now** on the Sites page or `POST /v1/project/sites/:id/health` (once every 10 seconds per site; sooner answers 429 with `retryAfter`).

## How paths map to files

File paths in a deploy are relative (`index.html`, `assets/app.3f2a.js`): no leading `/`, no `.` or `..` segment, no `\`, at most 512 bytes. A request's path is URL-decoded and looked up like this:

| Request | Serves |
| --- | --- |
| `/` | `index.html` |
| `/about/` | `about/index.html` |
| `/about` | `about`, else `about.html`, else a 301 to `/about/` when `about/index.html` exists |
| anything else not found | a [single-page app](https://dabadata.com/docs/sites/index.md#spa)'s `index.html`, else your `404.html` with status 404, else a plain 404 page |

Sites answer `GET` and `HEAD` only (anything else is a 405), and always over HTTPS: `http://` redirects.

## Single-page apps and 404.html

An app with client-side routing (React Router, Vue Router, Solid Router) has one `index.html` for every address. Mark the site `spa` - `--spa` when you create it, the switch on the console's Sites page, or `PATCH` with `{ "spa": true }` - and a path that matches no file, and whose last segment has no extension, gets `index.html` with status 200. So `/orders/42` loads the app, while a missing `/assets/app.js` is still a real 404 instead of HTML pretending to be JavaScript.

A site that is not a single-page app can ship a `404.html` at its root; it is served with status 404 for any path that matches no file.

## Caching and headers

- HTML is sent with `Cache-Control: no-cache`, so a new deploy shows at the next page load.
- A fingerprinted file - one whose name carries its build hash, such as `assets/main-pvlvU-M5.js` (Vite), `app.3f2a9c1e.js` (webpack) or anything under `_next/static/` - is `public, max-age=31536000, immutable`: new content gets a new name, so a browser keeps it for a year.
- Every other file, such as `gallery/photo-1.jpg` or `favicon.svg`, is `public, no-cache`: the browser asks each time and gets a short 304 when nothing changed, so a new deploy or a rollback shows at once even for files that keep their name.
- The edge cache is keyed by deploy, so publishing never serves a stale file from the edge. A visitor's browser may keep an asset up to an hour; build tools that put a hash in asset names (Vite, Next's export, Astro) avoid that entirely.
- `Content-Type` is the type the file was uploaded with (the CLI derives it from the extension; text types get `charset=utf-8`), with an `ETag`, `X-Dabadata-Deploy` (the deploy it came from), `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`. Sites never set cookies.

## Rollback

Every deploy is kept as it was: the newest 10 and the live one. Putting an older one back is instant, with no upload:

```bash
dabadata sites rollback dep_3f2a9c1e7b5d --site my-shop
```

The console's Sites page lists the deploys with their size, who made them and when, and rolls back from there too. Open a deploy's row to see its files (path, type and size); on the live deploy each path opens on the site. Older deploys, and uploads never published within 24 hours, are removed by the nightly job. Kept deploys count toward the workspace's [file storage](https://dabadata.com/docs/storage/index.md#limits).

## Move a site between test and live

A site built on **test** can move to **live** when it is ready, and back: on the console's Sites page press **Move to live** (or **Move to test**). The site keeps its name, its deploys and files, its domains and its readiness; nothing is uploaded, and its address keeps serving the same deploy without a gap. Its origins leave the old environment's [allowed origins](https://dabadata.com/docs/projects/index.md#cors) and join the new one's.

- Later deploys go to the new environment: `dabadata use prj_xxxxxxxxxxxx` for live (`:test` for test), or that environment's secret key.
- If the page calls dabadata, point it at the new environment's publishable key: the data stays where it was.
- A move needs a console session of a developer or up. A secret key reaches only its own environment, so it cannot move a site (403 `forbidden`). The site counts toward the same workspace, so the plan is not checked again.

## Your own domain

> **Google sign-in on your own domain:** If your app signs people in with [dabadata's Google app](https://dabadata.com/docs/sign-in-providers/index.md#dabadata-google), Google sign-in works on your `*.dabadata.site` address only, not on the domain you connect. Set up [your own Google app](https://dabadata.com/docs/sign-in-providers/index.md#google) first; your users keep their accounts.

From Starter, connect a domain you own to a site. One domain is one hostname plus, if you like, its redirect partner: `www.example.com` with `example.com` redirecting to it (or the other way round) counts as one.

```bash
dabadata sites domain add www.example.com --site my-shop --redirect-from example.com
# Add at your DNS host:
#   CNAME  www.example.com  ->  sites.dabadata.site
#   CNAME  example.com      ->  sites.dabadata.site   (needs CNAME flattening, ALIAS or ANAME)
dabadata sites domain check www.example.com
```

1. Add the domain in the console or with the CLI. It answers the DNS records to create.
2. At your DNS host, point each hostname at `sites.dabadata.site` with a `CNAME`. A bare domain (`example.com`, without `www.`) cannot carry a plain CNAME at most DNS hosts: use their CNAME flattening, `ALIAS` or `ANAME` record. If yours has none, serve the site on `www.` and let the bare domain redirect to it.
3. Wait. The HTTPS certificate is issued by itself once DNS points the right way, usually within minutes. The server checks pending domains every 5 minutes; checking in the console (or `domain check`) asks at once.

`--redirect-from` must be the same hostname with or without `www.`; requests to it get a 301 to the same path on the main hostname. A hostname under `dabadata.site` or the service's own domain, or one already connected to any site, is refused (409 `domain_taken`).

| Status | Means |
| --- | --- |
| `pending` | Waiting for DNS and the certificate. |
| `active` | The certificate is issued and the domain serves the site's live deploy. Its `https://` origin is added to the environment's [allowed origins](https://dabadata.com/docs/sites/index.md#calling-api). |
| `failed` | Still pending after 7 days. `error` says why (often a missing or wrong DNS record); fix it and check again. |
| `disabled` | The workspace has more domains than its plan allows (after a move to a smaller plan or an unpaid invoice). It comes back when the plan allows it again. |

Removing a domain (`dabadata sites domain remove www.example.com`) stops serving it at once.

## Calling your API from the site

A site is a frontend like any other: it calls `https://dabadata.com` with the environment's [publishable key](https://dabadata.com/docs/projects/index.md#keys) and the signed-in user's token, under your rules. Creating a site adds `https://<name>.dabadata.site` to the environment's [allowed origins](https://dabadata.com/docs/projects/index.md#cors), and a domain turning active adds its origin too, so there is nothing to configure; deleting either removes it. An environment with no allowed origins listed already accepts every origin and is left as it is.

```html
<script type="module">
  import { createClient } from "https://dabadata.com/client/dist/dabadata.js";
  // A site on the live environment uses the live publishable key.
  const db = createClient({ url: "https://dabadata.com", key: "pk_live_EXAMPLEexample0000000000" });
  db.subscribe("products", {}, { onChange: (c) => render(c) });
</script>
```

Never put the secret key in a site: everything in it is public.

## On Free: the badge and idle pause

- Pages of a site on Free carry a small "Hosted on dabadata" link in a corner. Paid plans have none; it goes away as soon as the workspace changes plan.
- A site sleeps with its environment. When a Free environment is [paused after 7 idle days](https://dabadata.com/docs/limits/index.md#idle-pause), its sites answer 503 "This site is paused", with a link for the owner to sign in. Visits to the site do not count as activity and do not wake it: free hosting is for apps that use dabadata. Any call with the environment's keys or tokens, or a deploy, wakes the environment and its sites.

A site over its plan's allowance (after a move to a smaller plan or an unpaid invoice) answers 503 "This site is over its plan" until the plan allows it again; a site taken down for abuse answers 451.

## Limits

|   | Free | Starter | Pro | Scale | Enterprise |
| --- | --- | --- | --- | --- | --- |
| Sites, workspace-wide | 1 | 3 | 10 | unlimited | unlimited |
| Custom domains, workspace-wide | 0 | 1 | 3 | 5 | 10 |
| Size of one deploy | 100 MB | 1 GB | 1 GB | 2 GB | 2 GB |
| Files in one deploy | 1,000 | 10,000 | 20,000 | 20,000 | 20,000 |
| "Hosted on dabadata" badge | yes | no | no | no | no |

- One file of a deploy may be at most the plan's largest file (50 MB on Free). Kept deploys count toward file storage. Serving costs nothing.
- A site or domain beyond the allowance is refused with 403 `{ "error": "plan_limit", "limit": "sites" }` or `"customDomains"`; on Free a domain gets `custom domains start with Starter`. A deploy that is too big gets 403 `plan_limit` with `limit: "siteBytes"`, `"siteFiles"` or `"maxFileBytes"`, before anything is uploaded.
- Moving to a plan with fewer sites or domains than the workspace has is refused with 409 `over_limit`.

See [Plans and limits](https://dabadata.com/docs/limits/index.md).

## HTTP API

The CLI, the console and the MCP tools use these routes; a script or CI job can too. **Who**: the environment's secret key, or a console session (viewers read, developers and up write). Details on [HTTP API: Sites](https://dabadata.com/docs/api/index.md#sites).

| Route | Does |
| --- | --- |
| `GET /v1/project/sites` | The environment's sites with their domains |
| `GET /v1/project/sites/available?name=` | Whether a name is free |
| `POST /v1/project/sites` | Create a site: `{ name, spa? }` |
| `PATCH /v1/project/sites/:id` | Change `spa` |
| `DELETE /v1/project/sites/:id` | Delete the site, its domains and deploys |
| `POST /v1/project/sites/:id/move` | [Move the site](https://dabadata.com/docs/sites/index.md#move) to the project's other environment: `{ to: "live" \| "test" }` (console session only) |
| `POST /v1/project/sites/:id/deploys` | Start a deploy: the file list in, a signed upload URL per file out |
| `POST /v1/project/sites/:id/deploys/:dep/publish?wait=` | Make an uploaded deploy live; `wait` (0 to 60 s) waits for the [readiness check](https://dabadata.com/docs/sites/index.md#readiness) |
| `GET /v1/project/sites/:id/deploys` | The deploys, newest first |
| `GET /v1/project/sites/:id/deploys/:dep` | One deploy and its files: `{ deploy, files: [ { path, size, type } ] }` |
| `POST /v1/project/sites/:id/rollback?wait=` | Make an earlier deploy live: `{ deploy }` |
| `POST /v1/project/sites/:id/health` | Check now whether the public hostnames serve the live deploy |
| `POST /v1/project/sites/:id/domains` | Connect a domain: `{ hostname, redirectFrom? }` |
| `POST /v1/project/sites/:id/domains/:dom/check` | Check a pending domain now |
| `DELETE /v1/project/sites/:id/domains/:dom` | Remove a domain |

A deploy by hand is three steps, which is what `dabadata sites deploy` does:

```bash
curl -X POST https://dabadata.com/v1/project/sites/$SITE_ID/deploys \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{"files":[{"path":"index.html","size":1834,"type":"text/html"}]}'
# -> { "deploy": { "id": "dep_...", "status": "uploading", "files": 1, "bytes": 1834 },
#      "uploads": [ { "path": "index.html", "url": "https://...r2.cloudflarestorage.com/...",
#                     "method": "PUT", "headers": { "Content-Type": "text/html" } } ],
#      "expiresAt": "..." }
curl -X PUT "$UPLOAD_URL" -H "Content-Type: text/html" --data-binary @dist/index.html
curl -X POST "https://dabadata.com/v1/project/sites/$SITE_ID/deploys/$DEPLOY_ID/publish?wait=60" \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY"
# -> { "deploy": { ... "status": "live" }, "site": { ... "health": { "status": "reachable", ... } } }
```

The upload URLs are valid an hour, with each file's exact size and type signed in. Publishing checks that every file arrived as declared; otherwise it answers 409 `{ "error": "upload_incomplete", "missing": [ path ], "files": [ { path, size, stored } ] }` (`stored`: the size that arrived, `null` when nothing did) and you upload those and publish again.
