Docs Reference
HTTP API
With a token, everything sitesolide deploy does is plain HTTPS to the dashboard. This page is for an agent, or a person, who holds a token but not the CLI: a ready-made script deploys any project, a static site, an app, services or workers, and each request it makes is described below.
What you need
Link to the section What you need| Variable | Value |
|---|---|
SITESOLIDE_API | The dashboard's address, https://dashboard.<zone>. There is no default; http:// is refused, but for localhost, 127.0.0.1 and [::1]. |
SITESOLIDE_TOKEN | A token, sst_ and 43 more characters, from the owner or minted on the dashboard's Tokens page: see Teammates and tokens. |
export SITESOLIDE_API=https://dashboard.<zone>
read -rs SITESOLIDE_TOKEN && export SITESOLIDE_TOKEN
Warning
The token is a password: whoever holds it deploys what it allows. Keep it out of command arguments, logs, the repository's files and the conversation. An agent gets it in its environment from whoever runs it. A token that leaked is revoked on Tokens.
Deploy with the script
Link to the section Deploy with the scriptsitesolide.fr/deploy.sh does exactly what sitesolide deploy does with a token. It needs bash, curl, tar (GNU or bsdtar), gzip, find, mktemp and one JSON reader, the first found of jq, node, bun or python3: neither rsync nor the CLI. Download it outside the project's folder, since an app's archive would carry it, and read it before you run it:
curl -fsSL https://sitesolide.fr/deploy.sh -o ~/deploy.sh
GitHub releases carry the same script beside install.sh, from the first release that ships it: https://github.com/cthiriet/sitesolide/releases/latest/download/deploy.sh. With both variables exported, run it from the project's folder, the one holding sitesolide.json:
bash ~/deploy.sh
| Command | Does |
|---|---|
bash deploy.sh [folder] | Deploys the folder holding sitesolide.json, the current one by default. |
bash deploy.sh --archive <file> [folder] | Runs the build and writes the archive. Sends nothing, needs no token, runs the project's code all the same. |
bash deploy.sh --help | Prints its usage. |
A deployment exits 0 only once the machine says succeeded, and non-zero otherwise; --archive and --help exit 0 once done. The rest of this page is what the script does, request by request.
Send the token
Link to the section Send the tokenEvery request carries Authorization: Bearer <token>. Hand the header to curl through a file descriptor: printf is a bash builtin, so the token is in no process's arguments and no history. -q, first, leaves ~/.curlrc unread, -g reads brackets in an address as brackets, and --proto =https refuses anything else. The examples below use this function:
api() { curl -q -sS -g --proto =https -H @<(printf 'Authorization: Bearer %s\n' "$SITESOLIDE_TOKEN") "$@"; }
api "$SITESOLIDE_API/api/v1/whoami"
{"identity":{"email":"alice@example.com","scope":{"slugs":["notes"],"create":true,"public":false,"outbound":false,"domain":false,"secrets":true},"owned":[],...}}
curl prints the body alone; the statuses below are given in the text, and -w '%{http_code}' prints them. Send the token to SITESOLIDE_API alone: build every address from it, never from one an answer names, and never follow a redirect.
Deploy, step by step
Link to the section Deploy, step by step- Check the folder. The CLI and the script refuse an app's code folder that holds
node_modules,.venv,venvor__pycache__at its top, unlessexcludelists that exact name (node_modules, not/node_modulesornode_modules/): the machine installs dependencies itself, withinstall.sourceandpublicDirstay inside the git repository holdingsitesolide.json, links resolved. Every name undersecrets.buildmust be set, in your environment, or for the CLI in~/.config/sitesolide/secrets/<slug>.env: one missing stops here, with nothing sent. - Send the manifest. It is judged before anything is built.
- Run the build, then check that
publicDirexists and is not empty. - Build the archive, then upload it.
- Follow the log until the deployment ends.
Send the manifest
Link to the section Send the manifestThe body is {"manifest": ...}, the text of sitesolide.json or its object. Every reason it is refused comes back at once, in details:
jq -Rs '{manifest: .}' sitesolide.json | api -H 'Content-Type: application/json' --data-binary @- "$SITESOLIDE_API/api/v1/deployments"
It answers 201, with the deployment's id:
{"deployment":{"id":"<id>","slug":"<slug>","state":"awaiting-bundle","creating":true,"log":[],"next":0,"error":null,"url":null,"allocated":[],...}}
The deployment waits 15 minutes for its archive. A token holds three waiting at most: a fourth replaces the oldest.
Run the build
Link to the section Run the buildThe manifest's build runs in the code folder, the manifest's source or its own folder, in a clean environment: from yours, only what locates your tools, PATH, HOME, the locale and the like; then the manifest's env, {zone} and {contact} replaced from the deployment's placeholders; then each name under secrets.build. Never a SITESOLIDE_ variable, the token among them, and never a value on a command line. It is the project's code and its dependencies', run as you all the same, with what you may read: run only a build you would run yourself. By hand, from the folder holding sitesolide.json, keep at least the token out of it:
build="$(jq -r '.build // empty' sitesolide.json)"
(cd <code folder> && env -u SITESOLIDE_TOKEN sh -c "$build")
Build the archive
Link to the section Build the archiveA gzip-compressed tar whose only top-level entries are app/ and public/, those the project has:
| Entry | Sent for | Holds |
|---|---|---|
app/ | An app: start or services. | The code folder, minus exclude, .git, .env*, sitesolide.json and the publicDir, each read as an exclusion pattern. |
public/ | A project with publicDir. | The publicDir folder, minus .git and .env*. |
Exclusion patterns follow rsync's rules:
- a name without a slash matches at any depth:
node_modules,.env*; - a pattern with a slash matches the end of the path, on a folder boundary:
cache/tmp; - a leading slash anchors it at the root of the folder sent:
/dist; *,?and[...]match within a name,**across folders;- a trailing slash matches folders only:
logs/.
Copy the trees into a stage outside the project's folder: inside it, the next deployment would carry the stage and the archive. For an app whose exclude is ["node_modules"] and whose publicDir is public, from its code folder, with one --exclude per pattern:
stage="$(mktemp -d)"
rsync -a --exclude node_modules --exclude .git --exclude '.env*' --exclude sitesolide.json --exclude public ./ "$stage/app/"
rsync -a --exclude .git --exclude '.env*' public/ "$stage/public/"
Stage by copying, rsync or cp -p, which keep the executable bit and modification times: Caddy derives a file's ETag from its size and date. Never stage by extracting a tar on macOS, which writes non-ASCII names decomposed: café.txt would reach the machine as another name than the one the site links to.
Then write the archive in pax, without the workstation's extended attributes, flags or owners. With macOS's tar, bsdtar:
COPYFILE_DISABLE=1 tar -czf "$stage/bundle.tar.gz" --format=paxr --no-xattrs --no-acls --no-fflags --uid 0 --gid 0 --uname root --gname root -C "$stage" app public
With GNU tar:
tar -czf "$stage/bundle.tar.gz" --format=pax --pax-option=delete=atime,delete=ctime,delete=mtime --owner=0 --group=0 --numeric-owner -C "$stage" app public
Name only the trees the project has: public alone for a static site, app alone without publicDir. Treat any warning tar prints as a failure, even when it exits 0: it may have left a file out. Not --format=ustar: macOS's tar drops a name over 100 bytes or a path over 255 with a warning, and exits 0. Not a bare --format=pax either: macOS's tar writes extended attributes in records that are not UTF-8, which the machine refuses, and both tars give every entry an extended header, which takes the machine's room for 20,000 entries. The forms above write one only where a long or non-ASCII name needs it.
The machine extracts the archive strictly:
- pax, ustar or GNU tar, every pax value in UTF-8;
- regular files and folders only: no symbolic or hard link, device or pipe, so replace a link with the file it points to;
- every path under
app/orpublic/, a leading./dropped: no absolute path, no.., no empty or.component, no backslash, no file at the top level, each path once; - a path of 1,024 bytes at most, each component 255;
- 100 MiB compressed, 512 MiB extracted, 20,000 entries, files and folders together.
bash deploy.sh --archive bundle.tar.gz writes the archive the script would send, and sends nothing: tar -tzvf bundle.tar.gz lists it.
Upload and follow
Link to the section Upload and followapi -X PUT -H 'Content-Type: application/gzip' --data-binary @"$stage/bundle.tar.gz" "$SITESOLIDE_API/api/v1/deployments/<id>/bundle"
It answers 202, the deployment running. A refused upload may have ended the deployment: read it before sending the archive again. Still awaiting-bundle, the dashboard's busy with three deployments running on the machine, send it again in a minute, within the 15 minutes. failed, a refusal from the machine itself, its own busy among them, start again from the manifest: the archive sent again would get 409 invalid.
api "$SITESOLIDE_API/api/v1/deployments/<id>?after=0"
{"deployment":{"id":"<id>","state":"succeeded","log":["..."],"next":42,"error":null,"url":"https://<slug>.<zone>/","allocated":[{"service":null,"port":3002}],...}}
Print log, pass next as the next after, and ask again about every second, until state is succeeded, failed or expired. A dropped connection ends nothing: ask again. Then remove the stage, rm -rf "$stage".
state | Means |
|---|---|
awaiting-bundle | The archive has not arrived. |
running | The machine installs it: keep reading. |
succeeded | Live at url. allocated lists the ports the machine chose, service null for a single start: write them into sitesolide.json. missingSecrets lists the declared names and files the store lacked: a missing secret never fails a deployment. |
failed | error.code names the step that stopped it, error.message what to do. |
expired | The archive never came within 15 minutes, or a newer deployment replaced this one: start again. |
| Route | What it does |
|---|---|
GET /api/v1/whoami | The token's holder, scope and projects. |
POST /api/v1/deployments { manifest } | Judges the manifest, opens a deployment for 15 minutes. |
PUT /api/v1/deployments/<id>/bundle | The archive; starts the installer. |
GET /api/v1/deployments/<id>?after=<n> | state, log from line n, next, error, url, allocated. |
GET /api/v1/projects | The projects the token reaches, with their status. |
GET /api/v1/projects/<slug> | One of them. |
GET /api/v1/projects/<slug>/logs?lines=<n>&cursor=<c> | The journal, 100 lines by default and 500 at most, and a cursor for the next call. |
POST /api/v1/projects/<slug>/restart | Restarts the service and watches it eight seconds: verdict.kind is active, looping or failure. |
GET /api/v1/projects/<slug>/store | The project's secret names, where each value comes from, what is missing, its files and its build's names, never a value. Needs Manage secrets. |
PATCH /api/v1/projects/<slug>/store { set, remove, restart } | Sets and removes variables, all or nothing; 201 when it created the store. Needs Manage secrets. |
POST /api/v1/projects/<slug>/store/generate { name, format, bytes, replace, restart } | Draws a random value on the server, never returned. Needs Manage secrets. |
PUT /api/v1/projects/<slug>/store/files/<name> { content, restart } | Sets a file read whole, its bytes in base64. Needs Manage secrets. |
DELETE /api/v1/projects/<slug>/store/files/<name> { restart } | Removes that file. Needs Manage secrets. |
GET /api/v1/projects/<slug>/access | General access, people with access, and the domains the token may give. |
PUT /api/v1/projects/<slug>/access { who } | Gives Can open to a company email, or to one of the company's @domains. |
DELETE /api/v1/projects/<slug>/access { who } | Takes a Can open entry off. |
Set a secret
Link to the section Set a secretA value never goes on a command line, and no route reads one back. The token needs Manage secrets, and may set any name of the project's store, declared in sitesolide.json or not. Read the value without echo, then hand it to jq on its standard input:
IFS= read -rs VALUE
printf '%s' "$VALUE" | jq -Rs '{set: {API_KEY: .}, restart: true}' | \
api -X PATCH -H 'Content-Type: application/json' --data-binary @- "$SITESOLIDE_API/api/v1/projects/<slug>/store"
unset VALUE
It answers 200, or 201 when the change created the store:
{"store":{"slug":"<slug>","variables":[{"name":"API_KEY","source":"store",...}],"missing":[],...},"created":false,"set":["API_KEY"],"generated":[],"removed":[],"absent":[],"files":{"set":[],"removed":[]},"restart":{"verdict":{"kind":"active",...}}}
A random key the app checks itself is better drawn on the server, where nobody sees it; a name already set is refused unless replace:
api -H 'Content-Type: application/json' -d '{"name": "SESSION_SECRET"}' "$SITESOLIDE_API/api/v1/projects/<slug>/store/generate"
A file goes whole, its bytes in base64, and each service reads it at $CREDENTIALS_DIRECTORY/files_<name>:
base64 < gcp.json | tr -d '\n' | jq -Rs '{content: .}' | \
api -X PUT -H 'Content-Type: application/json' --data-binary @- "$SITESOLIDE_API/api/v1/projects/<slug>/store/files/gcp.json"
Never a value read from the conversation. Names alone carry no secret: {"remove": ["OLD_KEY"]} goes on the line. A value holds on one line, 8 KiB at most; a file 64 KiB, and a project 32 files. Removing a name that a secret file from before still holds is refused, since that file's value would come back: the owner moves those files to the store. The route of before, PATCH /api/v1/projects/<slug>/secrets/<slug>.env, serves only a project whose manifest still declares <slug>.env.
Every refusal is {"error": "<code>", "message": "<what to do>"}, with details listing every reason a manifest is refused, and wait, in seconds, when the answer knows how long.
| Code | Status | What to do |
|---|---|---|
unauthenticated | 401 | The token is missing, unknown, expired or revoked: ask the owner, or mint another if it is your own. |
out-of-scope | 403 | The token may not do this, and the message says whom to ask. Never try another token. |
reserved | 403 | The slug belongs to the platform, starts like one of its projects (dashboard-*, portal-*), or is a removed project's whose backups or connector grants the machine still keeps: pick another. |
invalid-manifest | 422 | Fix every point of details, then send the manifest again. |
invalid | 400, 409 | The request is malformed, an access who that is neither an email nor a @domain or a secret the rules above refuse among them, or the archive went to a deployment that no longer waits for one: the message says which. |
not-found | 404 | No such deployment or project for this token, or a project not deployed yet. |
busy | 409 | A deployment of the project runs, or the machine runs three: try again in a minute, after reading the deployment on an upload. |
unmanaged | 409 | The project's store, one of its files or a secret file from before is not in a form the server rewrites: the owner fixes it on the server. |
already-present | 409 | Generating a name already set: replace draws a new one, and the old value stops working at the next restart. |
migrate-first | 409 | The manifest's units would stop reading a secret file from before whose values the store lacks: the owner merges it into the store first, sitesolide secrets migrate --restart, then deploy again. |
expired | 410 | The archive came after 15 minutes: start again from the manifest. |
too-large | 413 | A manifest over 64 KiB, or an archive over 100 MiB: exclude dependencies and caches. A change of secrets over 512 KiB: split it. |
too-many-attempts | 429 | Wait wait seconds. Without wait, the message says what to wait for, the hour's changes of access among them. Never retry in a loop. |
not-available | 503 | The machine does not carry this route yet: tell the owner, who runs sitesolide upgrade. |
failure | 500, 502 | Something broke on the machine: tell the owner, with the message. |
A failed deployment's error.code names the step that stopped it, with a message that says what to do:
error.code | What to do |
|---|---|
invalid-manifest, out-of-scope | Judged again on the machine, against its state and the token's scope at that moment: fix what the message names, or ask the owner. |
secret-taken | The project, in the form of before, would read a secret file not its own: pick another slug, or tell the owner. |
store-refused | The project's store on the server is not a folder root owns: tell the owner, nothing was changed. |
store-failed | The project's store could not be laid on the server, so nothing was served: tell the owner, who runs sitesolide upgrade. |
store-not-yours | A first deployment found the project's store made by another's write, never this token's: nothing was served, tell the owner. |
taken-meanwhile | The project was deployed on the server after this token's creation of it was accepted: pick another slug, or ask the owner. |
migrate-first | Judged again on the machine before any unit is laid, as above: nothing was changed. |
bundle-refused | The archive broke a rule above: the message names the entry. |
public-empty | publicDir arrived empty: make the build fill it. |
install-failed | install failed on the machine: run it in the project's folder and fix what it reports. |
port-taken, domain-taken | Drop port to let the machine choose one; a domain belongs to one project. |
service-failed | The service did not start, or fell within ten seconds: read its logs. A service that could not load its secret files says so: the owner runs sitesolide refresh <slug>. |
verify-failed | / must answer 200, or the portal's 401 on a Restricted site. |
system-unit | The slug names a service of the machine: pick another. |
caddy-busy, access-changed, machine-unreadable | Something changed on the machine meanwhile: deploy again in a minute. |
no-port, edited-by-hand, portal-not-ready, caddy-unknown, caddy-refused, loopback-outdated, loopback-failed, misconfigured, no-result | Tell the owner, with the message: only they can fix it. |
| Any other | Tell the owner, with the message and the deployment's id. |
What a token may not deploy
Link to the section What a token may not deployIn sitesolide.json, or on the machine | Refused unless |
|---|---|
portalExempt | The token may deploy public sites. |
A static site, with neither start nor services | The token may deploy public sites. |
| A project public on the machine | The token may deploy public sites. A person's own token deploys it as it stands. |
"network": "outbound", or egress | The token may use outbound network. |
domain | The token may declare a domain. |
secrets in the form of before naming a file other than <slug>.env, lock | Never. |
memory above 1G, more than 6 services | Never. |
A manifest without portal is not refused: a new project is Restricted unless the token may deploy public sites. A project that already exists keeps the general access the machine carries: it changes from the dashboard, never from a deployment.
Next steps
Link to the section Next steps- Teammates and tokens: mint a token, and pick its permissions.
- Manifest reference: every key of
sitesolide.json. - Agents: the CLI's JSON output and its MCP server, when the CLI is there.