# The manifest `sitesolide.json`, at the root of a project, is the only configuration file a project has. It is committed in your repository and dropped as-is at the root of the project on the machine, where the domain table and the lock generator read it back. An unknown key is refused, never ignored. A typo on `portal` or `lock` would deploy an open site its author believes is closed. ## The smallest ones A static site: ```json { "slug": "blog", "publicDir": "public" } ``` An app: ```json { "slug": "api", "port": 3030, "start": "/usr/local/bin/bun run server.ts", "publicDir": "public" } ``` A job that runs every night, and serves no page: ```json { "slug": "nightly", "services": { "sync": { "start": "/usr/local/bin/bun run sync.ts", "schedule": "daily" } } } ``` `start` is what decides: a project that declares one, or `services`, is an app and gets a systemd unit and a Caddy block; a project without either is a folder Caddy serves directly, and costs nothing at rest. The language does not matter. `start` is whatever runs your server, as long as it listens on `port`: `/usr/bin/node server.js`, the `uvicorn` of a virtualenv under `/srv/sites//app/.venv`, a Go binary built on your workstation. A process that listens on nothing, a worker, or one that runs at set times, a scheduled service, is declared under [`services`](#services). ## Every key ### `slug` (required) The project's name, and the only one it has. It becomes a DNS label (`.`), a directory (`/srv/sites/`), a system account (`site-`) and, for an app, a systemd unit. Lowercase letters, digits and hyphens, not starting or ending with a hyphen, 63 characters at most. `landing` is refused: it is reserved for the project that serves the bare domain. `machine` too: the backups keep the machine's own state, its secrets among them, under that name. So are the names of the services the machine runs, `caddy`, `ssh`, `cron`, `nftables` and the others `isSystemName` lists in `bin/cli/manifest.ts`, every `systemd-*` and `sitesolide-*`, `www`, which the landing serves, and `public`: Caddy roots a host at `/srv/sites//public`, and a host whose first label is empty, `.`, at `/srv/sites//public`, which is `/srv/sites/public`, the folder of a project of that name, its manifest and its code served from there past its portal or its code. A unit laid in `/etc/systemd/system` under such a name would replace the system's own, which lives in `/lib/systemd/system`. For the names no list foresees, `deploy` asks systemd itself before writing anything, and stops when it already knows a unit of that name that deploy did not write and that does not serve `/srv/sites/`. `--force` does not lift that refusal. ### `description` One line, shown by `systemctl status` and in the dashboard. Without it, the unit says `Project `. No control character, and no backslash at the end, which systemd would read as a line continuation; a `%` is written as `%%`, so systemd shows it as it is. ### `source` Where the code lives, when it is not beside the manifest: a path relative to the folder holding `sitesolide.json`. ```json { "slug": "mini-lab", "source": "../../mini-lab", "services": { ... } } ``` For a repository that should carry nothing about its deployment, an open-source project for instance: the manifest stays in your sites repository, and `sitesolide deploy` runs from there. The build, the exclusions, `publicDir` and the upload all start from `source`; general access changed from the dashboard is still written back into the manifest, where it lives. Never absolute, which would name the workstation that wrote it. From your sites repository, the one `sites` names in `config.json`, `source` may lead anywhere. A manifest found anywhere else, a repository you just cloned, may only point inside the repository that holds it, links resolved: a `source` of `../../.ssh` would otherwise have its build run there and the folder uploaded. ### `publicDir` The directory whose contents Caddy serves directly, relative to the project. `public`, `dist`, `build/public`, whatever your build produces. A project with neither `start` nor `services` must have one; there would be nothing to serve otherwise. These files never wake the service, which is why a static site costs no memory. Like `source`, it stays inside the repository holding the manifest, links resolved, unless that manifest is in your sites repository: validation refuses a `..`, but a link is none, and a cloned `public` linked to `../../.ssh` would have the workstation's keys sent and served to anyone. Inside it, a link that leads out of the folder, to an absolute path or by climbing out, is refused too, before anything is sent: Caddy follows links on the machine, and `robots.txt -> /etc/caddy/cloudflare.env` would serve the zone's DNS token. Where a link lands is judged as the kernel resolves it, segment by segment, every link met on the way followed from where it stands, never by reading `..` off its text: `a/b/c/d/up/../../../../etc` stays inside as text, and climbs out through `up -> ../../../..`. A loop lands nowhere, and is refused as well. A link that stays inside the folder travels as it is. A token's deployment carries no link at all. ### `build` A command run on your workstation before anything is sent. Typically `bun run build`, `npm run build`, a Tailwind compilation. It runs locally on purpose: the machine has no toolchain, no `node_modules` and no compiler, and does not need any. `deploy --dry-run` shows it and does not run it: it is the folder's own code, and a dry run is what an agent tries first on a folder it may have just cloned. `--dry-run --build` runs it too, to check what it produces. ### `install` A command run on the machine after the code arrives, typically `bun install --production`. Only for dependencies that cannot be built elsewhere; anything platform-independent belongs in `build`. It runs in `app/` as the project's own account, `site-`, in a transient unit with the walls of its service: the network but not the loopback, none of the machine's ports to listen on, nothing of `/srv` but `app/`, nothing of `/etc/sitesolide`, a throwaway `HOME`, 1G of memory and fifteen minutes at most. That holds for a deployment over SSH as for one through a token. A package manager runs lifecycle scripts, the package's own and those of the dependencies it trusts, and they run there too, never with the rights of the account that deploys. A step that writes outside `app/`, needs root, or relies on a tool installed into `HOME` fails: it belongs in `build`, or the tool on the machine. ### `start` What systemd runs. An absolute path: the unit has a minimal `PATH` and nothing to search. ```json "start": "/usr/local/bin/bun run server.ts" ``` It is written into the unit's `ExecStart=`, and must start with the program itself: a letter, a digit, `/`, `.` or `_` first. systemd reads a leading `+`, `!`, `@`, `-`, `:` or `|` as an instruction, `+` and `!` as "run as root", and these are refused, as a line break or a trailing backslash is. Quotes and `$PORT` keep their systemd meaning; a `%` is written as `%%`, so it reaches the program as it is. **The site's root has to answer.** `deploy` ends by requesting `https://./`, and only a 200 counts, or the 401 of a locked preview or of the portal. A 404 at `/` most often means nothing is served at all, so it fails the deployment, even for an app whose other paths work: one that answers only `/api/*`, say. For an app, the Caddy step then puts the previous configuration back, over SSH as through a token, and its block stays out; a static site's files stay in place, and the failure is reported. Serve something at `/` that answers 200. An app without `routes` receives `/` itself, even beside a `publicDir` holding an `index.html`, and must answer it; with `routes`, or for a static site, `/` comes from `publicDir`, which then needs its `index.html`. A redirect does not count either: `/` itself must answer. A project that serves no page answers a line of its own there, see [A project that serves no page](#a-project-that-serves-no-page). **And it has to keep running.** A process that starts and falls at once looks started to systemd for that instant. `deploy` reads every service that runs for good, servers and workers, right after their restart and again once ten seconds have passed, and fails, naming each, when one is no longer active or was restarted by systemd meanwhile: `sitesolide logs` says why. The wait runs beside the Caddy step and the check over HTTPS, and adds ten seconds to a deployment at most. ### `port` The loopback port the service listens on, between 3000 and 3099. Caddy reverse-proxies to it, and the unit hands it to the service as `PORT`. A worker and a scheduled service listen on nothing, declare none and are given none, see [`services`](#services). Leave it out and `deploy` picks one: the lowest the machine's other projects leave free, or the one the machine already gives this project. It writes it into `sitesolide.json` and says so; commit it, so that every later deployment keeps it. Only a project with a single `start` gets one this way; the other commands refuse a manifest without a port until `deploy` has written it. A port another project declares on the machine is refused, measured before the build and again under the Caddy lock, just before the manifest is deposited, so that two deployments running side by side cannot both take it. So are 3000 and 3001, the landing's and the shared service's, and the platform's own, 3022 for the dashboard, 3026 for the portal and 3029 for analytics, whether they are deployed yet or not. That range is not arbitrary: a nftables rule reserves it to Caddy and root, so no service can reach another one. Outside it, a service would be reachable by every other project on the machine. A project of several processes declares a port for each service that listens, see [`services`](#services). **A service listens on its own port alone of the machine's.** The rule judges who connects, not who listens, and Caddy connects to whoever listens on a port of the range: the unit therefore refuses the service Caddy's admin API, 2019, every port of 3000 to 3099 but its own, and the egress proxy's 3128 and 3129 (`SocketBindAllow=`, `SocketBindDeny=`). A worker and a scheduled service listen on none of them, nor does a backup command. A bind refused there fails with `EPERM`: a server that opened a second port in the range, or UDP on its own, and a worker that listened inside it, declare a service of their own, with its own `port`. A port outside those stays free, nobody being sent there. systemd applies these lines only when it is built with `+BPF_FRAMEWORK`, which `systemctl --version` lists; without it, it ignores them without a word. ### `routes` The paths that go to the service; everything else is served from `publicDir` without waking it. ```json "routes": ["/api/*", "/webhook/*"] ``` Each one goes into a Caddy `path` matcher: a path starting with `/`, made of letters, digits and `._~/*%-`, never under `/_portal`, which the portal owns. Without this key, everything that is not a file on disk goes to the service. With it, only these paths do. Use it when most of the site is static and only a form or a webhook is dynamic. `/` is then served from `publicDir` unless a route covers it, so `publicDir` needs its `index.html`: a 404 at the root fails the deployment, see [`start`](#start). ### `services` Several processes instead of one `start`, or a process that is not a web server: a web front, the API it calls, a worker behind them, a digest sent every morning. Each entry names a service and takes `start`, and optionally `port`, `routes`, `internal`, `memory`, `cpu`, `env`, `backup`, `schedule` and `timeout`. What an entry declares makes it one of three kinds: | Kind | Declares | Runs | |---|---|---| | server | `port` | for good, listening on its port, for Caddy, or for its siblings alone when `internal` | | worker | `"internal": true`, and no `port` | for good, listening on nothing: a queue's consumer, a bot holding a connection, an agent | | scheduled | `schedule` | at the times it names, each run to its end; between two runs nothing runs | ```json "services": { "web": { "start": "/srv/sites/lab/app/.venv/bin/python -m lab.web --port 3050", "port": 3050 }, "api": { "start": "/srv/sites/lab/app/.venv/bin/python -m lab.api --port 3051", "port": 3051, "routes": ["/v1/*"] }, "worker": { "start": "/srv/sites/lab/app/.venv/bin/python -m lab.worker", "internal": true, "memory": "1G", "cpu": "50%" }, "digest": { "start": "/srv/sites/lab/app/.venv/bin/python -m lab.digest", "schedule": "Mon..Fri 08:00", "timeout": "15m" } } ``` - **Units.** The first service that runs for good, a server or a worker, keeps the project's unit, `.service`; the others are `..service`, so a name starts with a letter and is never a systemd unit type such as `socket` or `timer`. They start with the main one and follow it when it stops or restarts, so restarting the project from the dashboard restarts all of them; a scheduled service follows it its own way, see [Scheduled services](#scheduled-services). - **Routes.** A service with `routes` gets those paths. The one without takes every other path, or, next to a `publicDir`, every path that is not a file there. Two services whose routes could match the same request are refused, and without a `publicDir` exactly one public service must take the rest, when there is one: a project with none serves no page, see [A project that serves no page](#a-project-that-serves-no-page). - **`internal`.** Caddy never reaches the service; only the project's other services do. A worker says it, and a scheduled service is internal whether it says it or not: neither takes `routes`. - **Ports.** Between 3000 and 3099, one per server; a worker and a scheduled service declare none, and are given no `PORT`. The loopback rule lets each project reach its own ports and nobody else's, so a service calls its siblings on `127.0.0.1:`, a worker as a server does, and a neighbour cannot. A rule laid before this existed has no room for that: `deploy` refuses before pushing anything, and says to lay the current one with `bin/deploy-loopback.sh close`. - **`memory`, `cpu` and `env`.** A service's own `memory` and `cpu` replace the project's; its `env` is added to the project's, and wins on a shared name. `PORT` is each server's own. - **`backup`.** A service that keeps a server database in the data folder, PostgreSQL or MongoDB, declares the folder it keeps live and the command that saves it consistently: see [A service's backup command](#a-services-backup-command). Never a scheduled service, which keeps nothing live between its runs. `start`, `port` and `routes` are then refused at the top level. `install`, `secrets`, `network`, `egress`, `connectors`, `exclude` and the directories stay the project's, shared by every service. A worker or a scheduled service is declared under `services` even alone: the single `start` is always a server. A Python project installs its virtualenv on the machine with `install`. Tell `uv` to use the system's Python: one it downloads lives under the deployment account's home, which `ProtectHome` hides from the service, and the virtualenv would point at nothing. ```json "install": "/usr/local/bin/uv sync --frozen --no-dev --compile-bytecode --python-preference only-system" ``` #### Workers A worker runs for good and listens on nothing. Its unit is a server's, with three differences, all for the sites that share the machine with it: - **It comes back, more and more slowly.** systemd restarts it however long it keeps failing, 2 seconds after the first failure, then longer after each further one, up to 5 minutes; a deployment or a restart starts the count again. A worker that dies at once on an outside API's error would otherwise call that API, and pay for it, every two seconds. The monitor reports one crashing in a loop, as it does any project's unit. - **It yields the processor to the sites.** `CPUWeight=20`, against 100 for every site, gives a busy site five times the worker's share when the processor is contended, and changes nothing when it is not. - **It is bounded.** `TasksMax=512` caps its processes and threads together, against a pool that grows without end. systemd stops it with SIGTERM, at a deployment, a restart from the dashboard or a stop, and kills it if it is still there a minute and a half later. A worker that handles SIGTERM finishes the step in progress and exits cleanly, as [examples/background/worker.ts](../examples/background/worker.ts) does. One that falls within ten seconds of a deployment fails it, see [`start`](#start). #### Scheduled services ```json "digest": { "start": "/srv/sites/lab/app/.venv/bin/python -m lab.digest", "schedule": "Mon..Fri 08:00", "timeout": "15m" } ``` A timer of its own, `..timer`, starts the service at the times its `schedule` names; each run goes to its end, and between two runs nothing runs and nothing is spent. **`schedule`** is a systemd calendar expression, what a timer's `OnCalendar=` takes: | `schedule` | Runs | |---|---| | `hourly` | at the top of every hour | | `daily` | every day at midnight | | `*:0/15` | every quarter of an hour, at :00, :15, :30 and :45 | | `Mon..Fri 08:00` | on weekdays at eight in the morning | | `*-*-* 06:30 Europe/Paris` | every day at half past six, Paris time, through its changes of hour | The times are the machine's, UTC on most cloud images, unless the expression names a time zone. The manifest only checks that the expression is safe to write into the timer: one line, no `%`, no backslash. Whether it is a time is systemd's to say, so `deploy` asks `systemd-analyze calendar` on the machine before anything is written, a dry run included, and stops on a schedule systemd does not read, naming it. On a workstation that has systemd, `systemd-analyze calendar "Mon..Fri 08:00"` prints the next time it names. **`timeout`** is how long one run may take before systemd stops it: `90s`, `15m`, `2h`, a day at most, an hour when absent. A run that hung would otherwise hold its unit for good, and the timer, which never starts a service still running, would skip every run after it. A run stopped for its time counts as failed. - **A missed time runs at boot.** A time that passed while the machine was down runs once, as soon as it is back (`Persistent=true`). - **A run never overlaps the previous one.** A time that comes while the previous run is still going is skipped; the next one runs as usual. - **A run starts on time.** systemd does not shift it to group it with others (`AccuracySec=1s`). - **A failed run is not retried.** The service is `inactive` after a run that succeeded, `active` during one, and `failed` after one that failed, until the next: the time its schedule names next runs it again. The monitor reports a failed run as a warning until a run succeeds, and a timer that stopped as an outage: see [monitor/README.md](../monitor/README.md). - **A deployment runs nothing.** It lays the timer and restarts it, and the first run is the next time the schedule names. A deployment, or a restart from the dashboard, restarts a run in progress, on the new code, and never starts one that was not going: systemd passes a restart on to the units that hang on the main one as a try-restart. - **The walls of its siblings.** The same account, data folder, secrets, network, `env`, `memory` and `cpu`, and the same lower share of the processor and bound on tasks as a worker. No `port`, no `routes`, no `backup`: the hourly snapshot copies its data as files. `sitesolide status` shows its schedule, when it last ran and how that ended, and when it runs next; `sitesolide logs` shows its runs beside the project's other services. **A project whose every service is scheduled** has no service that runs for good to be known by. Its main unit, `.service`, is then an anchor: it runs nothing, as the project's account and with no network, stays active once started, and wants the timers, so that they start at boot and after a deployment, and its restart restarts them. The dashboard, the steward and the monitor find the project by that unit, as they find any other. #### A project that serves no page A project whose services are all internal, workers, scheduled services or internal servers, and that has no `publicDir`, serves no page. Its address still answers, a 200 with one line: ```text This project runs in the background and serves no page. ``` rather than an empty page that looks served and is not. The address is kept on purpose: the deployment's check over HTTPS, the Caddy step and the gatekeeper ask every project's address, and the portal and a preview code close it as they close any other. `domain` is refused on such a project: a domain would be certified only to answer that line. #### What fits on the machine, and what does not A background service shares the machine with every site: its processor, its memory, its disk and its one address. The walls of its unit keep it from reading the sites, not from slowing them down. **What fits** is light work, mostly spent waiting on someone else's API: a queue's consumer, a bot holding a connection to a chat, an agent calling a model, a sync that pulls a few hundred records every quarter of an hour, a report sent each morning through a mail provider. It keeps what it needs in its data folder, SQLite above all, which the hourly snapshot copies consistently. **What does not belong on the machine that serves the sites:** - **A headless browser.** Chromium sandboxes itself with user namespaces and a setuid helper, which the unit's confinement refuses (`RestrictNamespaces`, `NoNewPrivileges`): it fails to start, or runs with its own sandbox turned off, rendering pages from anywhere on the machine that serves every site. It also takes hundreds of megabytes and a core per page. - **Heavy computing**: video encoding, a model run on the processor, large builds. The lower share of the processor lets the sites go first when they compete, but a job that keeps every core busy for an hour still slows every site for that hour, and `cpu` only makes the job last longer. - **Mass email sent from the machine itself.** The machine has one address, every site's. Mail sent in bulk from it puts that address on blocklists, and the hosting provider acts on abuse complaints against the machine, not against one project: every site pays for one project's newsletter. A mail provider's API, reached through `egress`, keeps that reputation off the machine. ### `env` Environment variables for the service. Never a secret: this file is committed, and validation refuses names that announce one. ```json "env": { "NODE_ENV": "production", "PUBLIC_URL": "https://{slug}.{zone}" } ``` Three markers are replaced at deploy time: `{slug}`, `{zone}` and `{contact}`. They are what let a committed manifest avoid naming your machine. A value is one word for systemd: no space, quote, backslash or line break. `Environment=` splits on spaces and unquotes, so `"x DATA_DIR=/elsewhere"` would set a second variable; such a value is refused rather than half applied. A `%` is written as `%%` and reaches the service as it is. ### `headers` HTTP headers added by Caddy for this project. ```json "headers": { "X-Robots-Tag": "noindex, nofollow" } ``` A project under the zone carries `noindex` by default until it moves to its own domain, so that a preview never competes with the real site in search results. Each value is written between double quotes in the project's Caddy block, so it is printable ASCII without `"`, `\`, `{`, `}`, `$` or a backtick: Caddy reads those as syntax, and `{$NAME}` or `{env.NAME}` as its own environment, which would serve its secrets to every visitor. What real headers use stays allowed: the single quotes, semicolons, colons, slashes and spaces of a `Content-Security-Policy`, the parentheses of a `Permissions-Policy`, the angle brackets of a `Link`. A name is letters, digits and dashes, starting with a letter. ```json "headers": { "Content-Security-Policy": "default-src 'self'; img-src 'self' data:" } ``` ### `exclude` Directories the upload leaves behind, on top of `.git` and the manifest. ```json "exclude": ["node_modules", "data", "tests"] ``` `.git` and every file whose name starts with `.env` never leave, at any depth, from the code nor from `publicDir`, whatever this list says: a history's `.git/config` may carry a token, and a `.env` holds secrets by convention. The deploy also removes them from the machine's `public/` when an earlier deployment left them there, and Caddy answers 404 for those names in every folder it serves. `node_modules` matters: dependencies built on macOS, poured onto a Linux machine, give a service that does not start, after erasing the one that worked. The deploy refuses to run if it finds one on disk that you have not excluded. ### `memory` The memory ceiling, as systemd's `MemoryMax` takes it: `256M`, `1G`. Defaults to 256M. A bare number would be read as bytes, so it is refused. ### `cpu` The processor ceiling, as systemd's `CPUQuota` takes it: a share of one core, `50%` for half of one, `200%` for two whole cores. ```json "cpu": "50%" ``` None by default: a service takes what the processor has free. At the top level it is every service's; a service's own, under `services`, replaces it. A worker and a scheduled service already yield the processor to the sites when it is contended, see [Workers](#workers); `cpu` caps a service even when it is not, for work that would otherwise take every core for as long as it lasts. Refused on a static site, where nothing runs. ### `network` - `localhost` (default): the service can only reach the loopback. Outbound connections and DNS are blocked. - `outbound`: the service can reach the network. The blocking also cuts DNS, whose resolvers are external. A service that needs to call an API, a mail provider, a payment processor, needs `outbound`, or, better, [`egress`](#egress) with the hosts it calls. ### `egress` The hosts the service may reach, and no others. ```json "egress": ["api.anthropic.com", "*.slack.com", "db.example.com:8443"] ``` The unit keeps the default network, the loopback alone, and points the service's HTTP clients at the egress proxy (`HTTPS_PROXY`, `HTTP_PROXY`, in both cases, and `NO_PROXY` for the loopback), which lets these hosts through and refuses the rest with a sentence naming the host. Bun's `fetch`, curl, Go and Python read those variables by themselves; Node's built-in `fetch` does not, see [egress/README.md](../egress/README.md#which-clients-go-through-it). - **Host names, never addresses.** Lowercase or not, with or without the final dot, international names in either form: they are compared in their ASCII form. - **`*.example.com`** matches every subdomain, never `example.com` itself, which is listed on its own when wanted. A wildcard needs two labels after it. Over a domain where anyone gets a subdomain, `*.github.io`, it opens everything hosted there. - **Ports**: 443 and 80 without one; any other is written in the entry, and then that port alone. - **Every address a host resolves to is checked** by the proxy: one that points inside the machine or a private network, or at the cloud's metadata service, is refused. Refused next to `"network": "outbound"`, which already reaches everything, and on a static site, where nothing runs. The deployment refuses a project that declares it, before pushing anything, while the egress proxy is not installed on the machine. A change to the list applies at the service's next connection. `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY`, in both spellings, then belong to the deployment, and `env` may not set them. ### `connectors` Credentials the machine lends the service without handing them over. ```json "connectors": ["slack"] ``` An administrator defines `slack` on the dashboard's Connectors page, its base address and the header that carries the credential, and grants it to the project. The service calls ``` $SITESOLIDE_CONNECTORS/slack/chat.postMessage ``` in plain HTTP on the loopback, `http://127.0.0.1:3129/connectors/slack/...`, and the egress proxy forwards it over HTTPS with the header set. The credential appears in neither the repository, nor the unit, nor the service's environment. **Both are needed**: this key, which says the app asks, and the grant, which says the administrator agrees. Either alone is refused. Names are lowercase letters, digits and dashes, starting with a letter, and never `constructor`, which every JavaScript object already carries. Works with either `network`; refused on a static site. `SITESOLIDE_CONNECTORS` belongs to the deployment. ### `secrets` The environment files the service reads, from `/etc/sitesolide`. ```json "secrets": [".env"] ``` Plain file names, no path: letters, digits, `.`, `_` and `-`, starting with a letter or a digit. systemd reads them as root before dropping privileges, so the service receives the variables without ever being able to read the files. A project that declares none has the whole directory made inaccessible, which hides even the file names. **A project declares its own files alone**, since root reads whatever it declares: `.env` and `-`, never the machine's own, `dashboard.env`, `dashboard-monitor.env`, `dashboard-backup.env`, nor one a more specific site deployed on the machine holds, `acme-crm.env` for `acme` when `acme-crm` is there; and a file already on the machine must be a plain file of `site-`. `deploy` refuses the others before anything is pushed, naming them. A token's project declares `.env` alone. Their values live on the machine and are managed from the dashboard. The repository declares that a secret is expected, never what it contains. ### `domain` The project's own domain, once it has one. ```json "domain": { "name": "example.com", "aliases": ["www.example.com"], "active": true } ``` `active` is written by `sitesolide domain --activate`, not by hand: that command also rebuilds the domain table, and without that step the certificate would never be authorised. A token's deployment never switches it: the machine's value stands, whatever the manifest says, and only for the names it already covered, a new name or alias staying inactive until the owner activates it. A domain under the served zone is refused, the wildcard already covers it, and so is a domain for a project that serves no page, see [A project that serves no page](#a-project-that-serves-no-page). The domain is the site's address once it is active: its aliases, and the `www` of a second level domain, which the table adds by itself, answer with a permanent redirect to `name`, the path kept. An alias in the served zone is refused, and so is a name, an alias or their `www` that another project on the machine declares, active or not: a name belongs to one project. It opens as the preview does, Restricted or with a code alike: the domain closes with the site, and `sitesolide domain --activate` on a closed site checks that what Caddy serves closes it too. ### `lock` Not written by hand. It says the site is closed behind a code, its preview and its own domain alike, its general access *Anyone with the code*, and the machine is its source of truth, as for `portal`: the site's *Access* section in the dashboard, or `sitesolide lock` in its folder, sets it on the machine with the code, and the next `sitesolide deploy` writes it here, to commit. The code itself lives on the machine and never enters the repository. `lock` and `portal` never go together. ### `portal` and `portalExempt` ```json "portal": true, "portalExempt": ["/webhook/*"] ``` `portal` makes the project's general access Restricted: Caddy asks the portal before every request. `portalExempt` lists the paths that stay open to anyone, guarded by the app alone: signed webhooks, mostly, which carry their own proof and have no cookie. **Who gets in** is not in the manifest: the owner's password, the admin emails, and the project's people with access, given from the dashboard's *Access* section or with `sitesolide share` ([commands.md](commands.md#access-to-a-project)), each signing in with their company account or with password access. **The service learns who came in** from three request headers the generated block sets on every request that went through the portal: | Header | Value | |---|---| | `X-Sitesolide-User` | the verified email, lowercase; absent for the owner's password and for password access | | `X-Sitesolide-User-Name` | the display name, percent-encoded UTF-8; absent when unknown | | `X-Sitesolide-Role` | `admin` for the owner's password and the admin emails; the person's role otherwise, `visitor` (Can open), `viewer`, `developer` or `admin`; `visitor` for password access. Before the access registry it read `member` or `guest`: an app that compared it with either must be updated | The block takes any `X-Sitesolide-*` header the visitor sends off every request first, exempted paths included, and their underscore spellings, so a protected service can trust them. A public service gets none: its block, and its customer domain's, take them off too, so it is never handed a stranger's `X-Sitesolide-Role: admin`. A site deployed before these headers existed, protected or not, keeps working, takes nothing off, and gains them at its next `sitesolide deploy`. Reading them in a Bun service: [portal/README.md](../portal/README.md#who-came-in-the-identity-headers). **For a deployed project the machine is the source of truth.** General access is set in the dashboard's *Access* section; `sitesolide deploy` reads what the machine carries and rewrites the local manifest, which you then commit. Every command refuses to contradict it: a repository that reopened a site the dashboard closed would be the worst possible failure. ### `backup` ```json "backup": false ``` Every app's `data/` folder is snapshotted by the machine every hour, once the backup component is installed (see [dashboard/src/backup/README.md](../dashboard/src/backup/README.md)), and can be restored from the dashboard's *Backups* section. `false` keeps it out: a folder that only holds a cache, or a copy of something kept elsewhere, need not take room on the backups disk every hour. `false` is the only value that keeps the data out: absent already means backed up, and `true` would be a second way of writing it. A static site has no data folder, so the key is refused there. The other value is an object, a service's backup command, below. ### A service's backup command The hourly snapshot copies every file of `data/` as it is, and every SQLite database consistently. A server database is neither: PostgreSQL, MongoDB and their kin write their files continuously, and a copy taken file by file while they run reports success and restores a cluster that does not start, or starts corrupt. A service that keeps one in the data declares how it is saved: ```json "postgres": { "start": "/bin/sh /srv/sites/shop/app/postgres.sh", "port": 3081, "internal": true, "backup": { "folder": "postgres", "command": "/bin/sh /srv/sites/shop/app/postgres-backup.sh" } } ``` - **`folder`**, relative to `data/`: the folder this service keeps live. The snapshot never archives its files. A name or a path of names, `postgres` or `db/main`, with no `..`, no `.` and no empty part; never the data folder itself, so a cluster lives in a folder of its own, not straight in `DATA_DIR`. Two services cannot declare the same folder, nor one inside the other. - **`command`**, run just before the copy. **Its contract: leave in `$BACKUP_DIR`, an empty folder, a consistent copy that the service can start from as `folder`.** Exit 0 and a non-empty `$BACKUP_DIR` are required. The archive then holds that copy under `data/`, and a restore puts it back in place of the live folder, 0700 and the project's, as it was made. The command follows the rules of `start`: it starts with the program, an absolute path, never a prefix systemd reads as root, and `$NAME` is expanded from its environment, by systemd, into the command line. A secret is therefore read by the program from its environment, as the script below does, never written as `$NAME` in the command, where its value would show in the list of processes. It is one program and its arguments, quotes keeping spaces in a word: no lone `;`, no escape but `\\`, `\"`, `\'`, `\s`, `\n` and `\t`. Anything more belongs in a script, as in the recipe below. It runs as the project's account, `site-`, in the walls of its service: the same `app/` and `public/` read-only and `data/` writable, the same working directory, `app/`, and its service's environment, `PORT`, `DATA_DIR`, `PUBLIC_DIR` and `env` with its placeholders replaced, plus `BACKUP_DIR`, which `env` may not set beside a backup command. Its secrets arrive as its service's do, read by systemd from `/etc/sitesolide`, the folder itself hidden from it. It reaches the loopback alone, whatever `network` says: enough to talk to its service on its port, which the machine's loopback rule lets a project's account do for its own ports and nobody else's; never the egress proxy's variables. It listens on none of the machine's ports, its service's included, which its service holds. It shares the project's time in the run with the copy, its share of a 25-minute window, 20 minutes at most, has its service's `memory` and 128 MiB more for the program that runs it, and is stopped when the disk comes down to the backup component's reserve. `$BACKUP_DIR` is on disk, never in memory, under `/var/cache/sitesolide-backup/`. Its removal is tried once the snapshot is over, for a few seconds; what it leaves, the next run removes before the command runs again, whatever modes the command left. The command may print what it likes: its last lines go to the journal, and only how it exits decides. A server whose snapshot is written by the server itself, through its own API, cannot write into `$BACKUP_DIR`, which the service, read-only outside its data, does not reach: it writes into its data folder, and the command copies the result into `$BACKUP_DIR`, then removes it. A copy, not a rename, the two folders being different mounts. A command that fails, runs past its time or leaves nothing fails that project's snapshot, and says so in the dashboard and in the status file the monitor reads; what it printed goes to the backup component's journal, `journalctl -u sitesolide-backup`, never to the status file. The snapshot a restore takes of the data it replaces runs no command: the services are stopped, and the live folder is saved as its files. A running PostgreSQL or MongoDB found in the data that no service declares fails the snapshot, rather than archiving files that may not restore, and the status says to declare this key. Any other server needs it just the same; the backup component only knows how to recognise those two. **In the form with one `start`**, the project's own `backup` takes the object, as `start` and `port` are its single service's own: `"backup": { "folder": "index", "command": "/srv/sites/search/app/snapshot" }`. Under `services`, the top level keeps `false` alone, which refuses every service's command. A project that declares a backup command reaches its own ports through the machine's loopback rule, like a project of several services: `deploy` refuses it on a machine whose rule predates the project set, and says to lay it again with `bin/deploy-loopback.sh close`. It also warns when the machine's backup component predates backup commands, which it would then ignore, the live folder still copied as files: `sitesolide upgrade` brings it up to date. #### PostgreSQL Tested end to end by `dashboard/scripts/postgres-backup-proof.sh`, on Debian 13 and PostgreSQL 17: a snapshot taken while rows are written, restored by the backup component, and PostgreSQL started on it with every row committed before the backup, `pg_amcheck` finding no corruption. Its own cluster, reached on the loopback, saved by `pg_basebackup`, whose plain copy with its WAL PostgreSQL starts on as it is. On the machine, once, as root: PostgreSQL's binaries without the shared cluster Debian would create and start on port 5432 for everyone. ```bash sudo apt install postgresql-common sudo sed -i 's/^#\? *create_main_cluster.*/create_main_cluster = false/' /etc/postgresql-common/createcluster.conf sudo apt install postgresql-17 pg_lsclusters # lists none ``` The manifest: ```json "services": { "web": { "start": "/usr/local/bin/bun run server.ts", "port": 3080 }, "postgres": { "start": "/bin/sh /srv/sites/shop/app/postgres.sh", "port": 3081, "internal": true, "memory": "512M", "backup": { "folder": "postgres", "command": "/bin/sh /srv/sites/shop/app/postgres-backup.sh" } } }, "secrets": ["shop.env"] ``` `shop.env`, from the dashboard's *Secrets*, holds `POSTGRES_PASSWORD`. The web service reaches the database at `127.0.0.1:3081`. `postgres.sh`, the service: the cluster in `data/postgres`, made at the first start, TCP on the loopback only, no unix socket and no shared memory segment, which the service's private `/dev` does not have. ```sh #!/bin/sh set -eu PG_MAJOR=17 BIN="/usr/lib/postgresql/$PG_MAJOR/bin" if [ ! -x "$BIN/postgres" ]; then echo "PostgreSQL $PG_MAJOR is not installed on this machine (apt install postgresql-$PG_MAJOR)" >&2 exit 1 fi PGDATA="$DATA_DIR/postgres" if [ ! -s "$PGDATA/PG_VERSION" ]; then umask 077 pwfile="$DATA_DIR/.initdb-pw" printf '%s' "$POSTGRES_PASSWORD" > "$pwfile" "$BIN/initdb" -D "$PGDATA" -U app --pwfile="$pwfile" -A scram-sha-256 -E UTF8 --no-locale rm -f "$pwfile" echo "CREATE DATABASE app;" | "$BIN/postgres" --single -D "$PGDATA" -c dynamic_shared_memory_type=mmap postgres > /dev/null fi if [ "$(cat "$PGDATA/PG_VERSION")" != "$PG_MAJOR" ]; then echo "The cluster in $PGDATA is PostgreSQL $(cat "$PGDATA/PG_VERSION"), not $PG_MAJOR: upgrade it with pg_upgrade first" >&2 exit 1 fi exec "$BIN/postgres" -D "$PGDATA" \ -c listen_addresses=127.0.0.1 -c port="$PORT" -c unix_socket_directories='' \ -c dynamic_shared_memory_type=mmap -c shared_buffers=128MB -c max_connections=40 ``` `postgres-backup.sh`, its backup command: the superuser that `initdb` made has the replication right, and the `pg_hba.conf` it wrote lets it in from `127.0.0.1` with its password. ```sh #!/bin/sh set -eu export PGPASSWORD="$POSTGRES_PASSWORD" exec /usr/lib/postgresql/17/bin/pg_basebackup -h 127.0.0.1 -p "$PORT" -U app --no-password \ -D "$BACKUP_DIR" -X stream -c fast ``` `-X stream` puts the WAL written during the copy into it, which is what makes it start; `-c fast` asks for the checkpoint at once rather than waiting for the next. The cluster keeps no tablespace outside its folder: the copy leaves links out. Restored, PostgreSQL replays the copy's WAL at its first start and says `consistent recovery state reached` in its journal. #### Other databases The contract above is generic: any server whose own tool leaves a copy it can start from fits it, MongoDB with its own dump or snapshot tool, a search engine with its snapshot command. Only the PostgreSQL recipe has been tested here; write and test a command for any other, a restore included, before trusting it.