# Publish targets

> Where published sites go: the built-in /p/ hosting, and the local directories, SSH servers and Cloudflare Pages accounts you configure

A space, folder or note can be published as a static documentation site. Every server can host sites itself at `/p/`. You can add other destinations in `config.toml`: a directory on the Kribble host, an SSH server, or Cloudflare Pages. How people publish is in [Publishing sites](../using-kribble/publishing-sites.md). This page is about the server side.

## Who can publish

Publishing a space, folder or note, or building it again, needs space admin rights on its space (or ownership of the organisation). Through the API, the CLI and MCP, an access token with the `publish` or `write` scope acts for its creator ([Organisations and access](../running-a-server/organisations-and-access.md)).

## How a site is built

1. Someone publishes. `kribble-web` checks the request, stores the site with status `queued` and returns at once.
2. `kribble-builder` looks for queued sites every 10 seconds. It claims one (status `building`), renders every note to HTML in a temporary directory, and adds a markdown copy of each page, `llms.txt`, `llms-full.txt` and `kribble-docs.json` for AI agents.
3. It copies the result to the site's target and sets the status.

Up to `[builder].workers` sites build at once. Publishing a site that is already `queued` or `building` doesn't queue it twice.

| Status | Meaning |
| --- | --- |
| `queued` | Waiting for the builder. A site that stays here means the builder isn't running. |
| `building` | Being built now. If the builder stops during a build, it puts the site back to `queued` when it starts again. |
| `published` | Built and copied to its target. |
| `failed` | The site couldn't be generated (for example a note that couldn't be loaded). The builder log has the reason. |
| `publish_failed` | The site was generated, but copying it to an external target failed. The error is stored with the site, and the publish dialog and `kribble sites` show it. |

Each claim counts as an attempt. When the builder starts and finds a queued site with 3 or more attempts, it marks it `failed` instead of building it again, so a site that crashes the builder can't do so forever. Publishing or rebuilding again resets the count. To retry after `failed` or `publish_failed`, fix the cause and rebuild: **Publish** in the web app, or `kribble redeploy`.

## The built-in target

Sites published to the built-in target (`kribble`) are stored in Kribble's file storage and served by `kribble-web`:

- At `https://<server>/p/<organisation>/<slug>/`, or `/p/~<username>/<slug>/` for a personal vault. The slug is chosen when publishing and must be unique within the organisation.
- **Public** sites are readable by anyone. Their pages are stored under `sites/<organisation id>/<slug>/` in the public storage area (`<dir>/public`, or `public_bucket`), and their images and other assets load from `[storage.s3].public_bucket_url`, which must be `https://<server>/files-public`.
- **Space members** sites are stored in the private area and readable only by members of the space. People who aren't signed in are sent through `/login` and back. The CLI reads them with its access token (`read` scope). Access is checked on every request, and removing someone from the space or organisation takes effect at once.
- Switching a site between public and space members moves its files to the other area.
- Unpublishing deletes its files. Deleting a space or organisation deletes its sites.

There are no custom domains for the built-in target. To serve a site under its own domain, use one of the targets below, or proxy that domain to `/p/<organisation>/<slug>/` yourself.

## External targets

You define external targets in `config.toml`. Every key is listed in [Configuration](../running-a-server/configuration.md). They have these rules in common:

- **`name` is the identity.** Names are unique across all target types. Each site stores the type and name of its target, so renaming or removing a target breaks its sites: their rebuilds end in `publish_failed`, and unpublishing can't clean up the files.
- **`allowed_orgs` decides who sees the target** in the publish dialog, `kribble targets` and MCP. List organisation names (lowercase), or `"*"` for every organisation. An empty or missing list means nobody, so you can add a target before giving access. A personal vault is allowed by its generated name, like `vault-3f9a1c0b7d2e`, or by `"*"`. Taking an organisation out of the list stops new sites, but existing ones still rebuild.
- **External sites are always public.** The target can't check Kribble's space membership, so the space-members option is ignored.
- **Assets are included in the output**, so the site works without Kribble.
- **The target is fixed when the site is first published.** To move a site, unpublish it and publish it again to the new target.
- **Checked at startup.** A target without its required keys stops both binaries with an error.

:::callout{type="danger"}
For local and SSH targets, each site goes into a subdirectory of the target's base directory, and **that directory is emptied before every build**. So a subdirectory is required, and Kribble refuses one that another site on the same target already uses. The publish dialog, `kribble deploy` and MCP suggest one made from the site's name. Still, never point `base_dir` or `remote_base_dir` at a directory holding anything else, and don't nest one site's subdirectory inside another's.
:::

Unpublishing a site on an external target deletes it there: it empties the local or remote subdirectory, or deletes the Cloudflare Pages project.

### Local directory

`[[publish_targets.local]]` copies each site to `<base_dir>/<subdirectory>` on the Kribble host, for a web server on the same machine to serve. Files are written under a temporary name and renamed, so readers never see half-written files.

```toml
[[publish_targets.local]]
name         = "docs-www"
base_dir     = "/var/www/docs"
allowed_orgs = ["acme"]
```

- The builder writes the files, and `kribble-web` deletes them when a site is unpublished. Both run as `kribble` and need write access to `base_dir`. With the systemd units from `install.sh`, allow the path in both, because they make everything outside `/opt/kribble` read-only:

  ```sh
  sudo mkdir -p /var/www/docs && sudo chown kribble:kribble /var/www/docs
  sudo systemctl edit kribble-builder   # add: [Service] ReadWritePaths=/var/www/docs
  sudo systemctl edit kribble-web       # the same
  sudo systemctl restart kribble-web kribble-builder
  ```
- In Docker, mount the directory into both containers at the same path as `base_dir`.
- Serve it with your web server, for example nginx:

  ```nginx
  server {
      listen 443 ssl;
      server_name docs.example.com;
      root /var/www/docs/handbook;   # the site's subdirectory
      index index.html;
      location / {
          try_files $uri $uri/ $uri.html =404;
      }
  }
  ```

  Add `text/markdown md;` to the `types` block in `/etc/nginx/mime.types`, so browsers show each page's markdown copy instead of downloading it.

### SSH server

`[[publish_targets.ssh]]` uploads each site over SFTP to `<remote_base_dir>/<subdirectory>`.

```toml
[[publish_targets.ssh]]
name             = "docs-prod"
hostname         = "docs.example.com"
port             = 22
username         = "deploy"
private_key_path = "/etc/kribble/keys/docs_deploy"
passphrase       = ""                              # only for a key with a passphrase
known_hosts_path = "/etc/kribble/keys/known_hosts"
remote_base_dir  = "/var/www/docs"
allowed_orgs     = ["acme"]
```

To set it up:

```sh
sudo mkdir -p /etc/kribble/keys
sudo ssh-keygen -t ed25519 -N "" -C kribble-publish -f /etc/kribble/keys/docs_deploy
sudo sh -c 'ssh-keyscan -t ed25519 docs.example.com > /etc/kribble/keys/known_hosts'
sudo chown -R kribble:kribble /etc/kribble/keys
sudo chmod 600 /etc/kribble/keys/docs_deploy
```

Then add `/etc/kribble/keys/docs_deploy.pub` to `~deploy/.ssh/authorized_keys` on the remote host, and make sure `deploy` can write to `remote_base_dir` and the SSH server allows SFTP. Check the host key you scanned against the remote host's own key before you trust it.

- **The host key is always checked** against `known_hosts_path`. There is no option to skip it. A target without `known_hosts_path` starts, but every publish to it fails.
- Kribble offers Ed25519, ECDSA and RSA with SHA-2 host keys, not SHA-1 `ssh-rsa`. The scanned key should be one of those types.
- A key with a passphrase needs `passphrase`. Without it, publishing fails with a message saying so.
- The connection times out after 15 seconds.
- Keep the keys out of `/home`. The systemd units hide it from Kribble. In Docker, mount the key directory into both containers.

### Cloudflare Pages

`[[publish_targets.cloudflare_pages]]` creates one Cloudflare Pages project per site and uploads it with the Direct Upload API.

```toml
[[publish_targets.cloudflare_pages]]
name              = "cf-pages"
account_id        = "<cloudflare-account-id>"
api_token         = "<cloudflare-api-token>"
production_branch = "main"
project_prefix    = "kribble"
allowed_orgs      = ["acme"]
```

- **The API token** needs the account permission **Cloudflare Pages: Edit**. Create it under **My Profile → API Tokens** in the Cloudflare dashboard, limited to the account. The account ID is on the account's overview page.
- **Project names** are `<project_prefix>-<published name>-<slug>`, lowercased with other characters replaced by `-`, and at most 63 characters. The name is fixed when the site is first published, so renaming the space later doesn't rename the project. Kribble creates the project if it doesn't exist.
- **The address** is `https://<project>.pages.dev/`, which `kribble sites` and the publish dialog show.
- **`production_branch`** (default `main`) is set on the project and used for every deployment, so each build is a production deployment.
- **Files over 25 MiB** can't be uploaded to Pages. A site with one ends in `publish_failed`.
- **Custom domains** are added to the project in the Cloudflare dashboard (**Workers & Pages → the project → Custom domains**). Kribble doesn't manage them, and they stay when the site is rebuilt. Unpublishing deletes the project, custom domains included.

## Publishing from the CLI and agents

People with the `kribble` CLI can publish without the web app ([The kribble CLI](../using-kribble/the-kribble-cli.md)):

```sh
kribble targets                                   # where sites can go, per organisation
kribble deploy "Handbook" --target local --target-name docs-www --subdir handbook --wait
kribble deploy "Engineering/SDK" --slug sdk --access space_members
kribble sites                                     # every site, its URL and status
kribble redeploy "Handbook" --wait                # build again after notes changed
```

`kribble deploy` publishes a space, folder or note, or builds it again with its existing settings if it is already published. AI agents do the same over MCP with the `list_deploy_targets`, `deploy_site`, `redeploy_site` and `list_sites` tools ([AI agents](../using-kribble/ai-agents.md)). CI scripts can call `POST /api/v1/spaces/{spaceId}/publish` with a `publish`-scoped token.
