Docs The manifest
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
Link to the section The smallest onesA static site:
{ "slug": "blog", "publicDir": "public" }
An app:
{
"slug": "api",
"port": 3030,
"start": "/usr/local/bin/bun run server.ts",
"publicDir": "public"
}
A job that runs every night, and serves no page:
{
"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/<slug>/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.
Every key
Link to the section Every keyslug (required)
Link to the section slug (required)The project's name, and the only one it has. It becomes a DNS label
(<slug>.<zone>), a directory (/srv/sites/<slug>), a system account
(site-<slug>) 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/<first label>/public, and a host
whose first label is empty, .<zone>, 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/<slug>. --force does not lift that refusal.
description
Link to the section descriptionOne line, shown by systemctl status and in the dashboard. Without it, the unit
says Project <slug>. 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.
Where the code lives, when it is not beside the manifest: a path relative to the
folder holding sitesolide.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
Link to the section publicDirThe 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.
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
Link to the section installA 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-<slug>, 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.
What systemd runs. An absolute path: the unit has a minimal PATH and nothing
to search.
"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://<slug>.<zone>/, 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.
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.
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.
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.
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.
The paths that go to the service; everything else is served from publicDir
without waking it.
"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.
services
Link to the section servicesSeveral 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 |
"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,
<slug>.service; the others are<slug>.<name>.service, so a name starts with a letter and is never a systemd unit type such assocketortimer. 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. - Routes. A service with
routesgets those paths. The one without takes every other path, or, next to apublicDir, every path that is not a file there. Two services whose routes could match the same request are refused, and without apublicDirexactly 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. 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 takesroutes.- 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 on127.0.0.1:<port>, a worker as a server does, and a neighbour cannot. A rule laid before this existed has no room for that:deployrefuses before pushing anything, and says to lay the current one withbin/deploy-loopback.sh close. memory,cpuandenv. A service's ownmemoryandcpureplace the project's; itsenvis added to the project's, and wins on a shared name.PORTis 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. 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.
"install": "/usr/local/bin/uv sync --frozen --no-dev --compile-bytecode --python-preference only-system"
Workers
Link to the section WorkersA 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=512caps 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 does. One
that falls within ten seconds of a deployment fails it, see start.
Scheduled services
Link to the section Scheduled services"digest": { "start": "/srv/sites/lab/app/.venv/bin/python -m lab.digest", "schedule": "Mon..Fri 08:00", "timeout": "15m" }
A timer of its own, <slug>.<name>.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
inactiveafter a run that succeeded,activeduring one, andfailedafter 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. - 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,memoryandcpu, and the same lower share of the processor and bound on tasks as a worker. Noport, noroutes, nobackup: 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, <slug>.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
Link to the section A project that serves no pageA 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:
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
Link to the section What fits on the machine, and what does notA 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
cpuonly 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.
Environment variables for the service. Never a secret: this file is committed, and validation refuses names that announce one.
"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
Link to the section headersHTTP headers added by Caddy for this project.
"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.
"headers": { "Content-Security-Policy": "default-src 'self'; img-src 'self' data:" }
exclude
Link to the section excludeDirectories the upload leaves behind, on top of .git and the manifest.
"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.
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.
The processor ceiling, as systemd's CPUQuota takes it: a share of one core,
50% for half of one, 200% for two whole cores.
"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; 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
Link to the section networklocalhost(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 with the hosts it calls.
The hosts the service may reach, and no others.
"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.
- 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.commatches every subdomain, neverexample.comitself, 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
Link to the section connectorsCredentials the machine lends the service without handing them over.
"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
Link to the section secretsThe environment files the service reads, from /etc/sitesolide.
"secrets": ["<slug>.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: <slug>.env and <slug>-<name>, 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-<slug>. deploy refuses the others before anything is pushed,
naming them. A token's project declares <slug>.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.
The project's own domain, once it has one.
"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.
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.
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
Link to the section portal and portalExempt"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),
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.
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": 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), 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
Link to the section A service's backup commandThe 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:
"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 todata/: the folder this service keeps live. The snapshot never archives its files. A name or a path of names,postgresordb/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 inDATA_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 asfolder. Exit 0 and a non-empty$BACKUP_DIRare required. The archive then holds that copy underdata/<folder>, 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-<slug>, 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
Link to the section PostgreSQLTested 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.
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:
"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.
#!/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.
#!/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
Link to the section Other databasesThe 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.