Skip to content
sitesolide

Docs Commands

Commands

Every command runs from a project's folder, the one holding its sitesolide.json, except setup, upgrade, refresh, init, machine, people, help and --version. This is what sitesolide --help prints.

sitesolide setup <user@host>    install a fresh Debian 13 machine, resumable, a no-op once done
   --zone <dns.zone> --email <you@example.com>
   --contact <you@example.com>   shown on the page that asks for a preview code
   --user <name>                the account that deploys, deploy by default as root
   --skip-dns                   create the DNS records by hand: setup lists them and waits
   --dns-replace                replace records that point elsewhere, on your decision alone
   --cloudflare-token-stdin     read the Cloudflare token from standard input
   --minimal                    leave out backups, the installer and the egress proxy
   --any-os                     go on with a system other than Debian 13, at your own risk
   --config-dir <dir>           another installation's own configuration folder
   --dry-run                    check every step, change nothing
sitesolide upgrade              bring every installed component to this release's code, resumable
   --dry-run                    list each component, up to date, out of date or missing, change nothing
sitesolide refresh <slug>       its units generated again from the manifest on the server, no code sent
   --all                        every project, one at a time, stopping at the first that fails
   --dry-run                    show the units it would write, change nothing
sitesolide help                 this list, with or without a configuration; --help after any command
sitesolide --version            the release this binary was built from, dev from a checkout
sitesolide init                 write ~/.config/sitesolide/config.json
   --server <user@host> --zone <dns.zone> --email <you@example.com>
   --contact <you@example.com>   shown on the page that asks for a preview code
sitesolide detect               the sitesolide.json this folder implies, written nowhere
   --write                      write it, never over an existing one
   --slug <name>                name the project, rather than after its folder
sitesolide deploy               prepare, build, push, install, restart, verify
   --dry-run                    show the unit and the fragment, install nothing, build nothing
   --build                      with --dry-run: run the build too, the folder's own code, here
   --compare                    with --dry-run: build, then list what would change on the server
   --force                      switch a hand-written unit to the generated one
   --yes [--slug <name>]        no sitesolide.json: write the inferred one, then deploy
sitesolide status               what the server actually runs
sitesolide logs [--follow]      journalctl for this project
   --lines <n>                  how many lines back, 50 by default
sitesolide backups              this project's data snapshots, read only
sitesolide share                this project's general access and people with access
   <email|@domain>...           give them access, Can open by default
   --role <role>                can-open, viewer, developer or admin
   --expires <24h|7d|30d|never> for password access, 7d by default
   --remove <email|@domain>...  take their access away; password access by the name it is listed under
sitesolide people               everyone with access, their roles, who may create projects
   <email> --may-create         let them create projects, Admin of what they create
   <email> --no-create          take that right back
   --migrate-without-portal     carry access over without the portal's database, when it does not read
sitesolide lock   [--dry-run]   Anyone with the code: the site opens with a code, said once; or the one in force
   --status                     wanted / installed / measured, without touching
   --new-code                   a new code, the old one no longer opens it
sitesolide unlock [--dry-run]   back to Public, its code dropped
sitesolide domain               where this project's own domain stands
   --activate [--force]         switch the site onto it, then rebuild the table
   --deactivate                 back to the preview subdomain
sitesolide remove --confirm <slug>
                                take the project off the machine, for good, its name freed from the token that created it
   --dry-run                    show every step, remove nothing
sitesolide run -- <command>     load the secret from the vault and run
sitesolide mcp                  serve these commands to an agent, over MCP on stdio
sitesolide login --url <https://dashboard.zone>
                                a person with a token: deploy, set secrets and restart without SSH
   --token-stdin                read the token from standard input
sitesolide machine create|list|destroy --provider hetzner
                                a VM ordered by API, before setup: sitesolide machine lists the options
any command --api               go through the dashboard's API even with a server
SITESOLIDE_CONFIG_DIR=<dir>     before any command: read that installation's configuration

--json, on every command but init and run: one JSON event per line, see docs/agents.md
secrets live on the server: manage them in the Secrets section of https://dashboard.<zone>
general access of a deployed site is set from the dashboard too: deploy follows the server

sitesolide setup is the whole base install of install.md, hardening and DNS records included, in one command that can be run again at any time: see setup.md. sitesolide upgrade is its other half, for a machine in service: it brings the components setup installed to the code of the binary or the checkout it runs from, and redeploys only those that differ, see Upgrading the machine. sitesolide refresh gives the apps already deployed the units this release generates, without sending their code, see Refreshing the apps' units. SITESOLIDE_CONFIG_DIR points every command at another configuration folder than ~/.config/sitesolide, for a second installation driven from the same workstation.

sitesolide run loads the files the manifest declares from ~/.config/sitesolide/secrets/, the few credentials your workstation presents to production itself, then runs the command. See secrets.md.

sitesolide deploy reads the machine before anything is pushed, and refuses there what a cloned folder could reach through it: a secret file that is not the project's, <slug>.env and <slug>-<name> being its own, never the machine's, never one a more specific site deployed there holds, and when present a plain file of site-<slug>, whose owner it reads through sudo without opening it; a publicDir that is a link out of the repository holding the manifest, unless that manifest is in your sites repository; and a link among the public files that leads out of them, which Caddy would follow on the machine. Each refusal names the file or the link, and nothing was sent. See manifest.md.

sitesolide remove deletes the secret files the folder's manifest declares only when they are the project's by that same rule, and prints the others, kept: and why: the manifest is whatever the folder holds now, and a clone's naming dashboard.env had root delete the dashboard's password. It reads the sites on the machine and the files' owners first, and an answer that does not read deletes nothing. Its name is free again for a token that may create projects, once the machine keeps neither the removed project's backup snapshots nor its connector grants: until then a token creating it is refused reserved, and you deploy it over SSH, or clear what was left (dashboard/src/backup/README.md).

sitesolide logs shows every service of a project, interleaved by time, the runs of its scheduled services among them, each under its own unit, <slug>.<name>.service. A run that failed says why there, and a deployment that failed because a service fell right after its restart points there too. It reads the journal through sudo -n: the services write to the system journal, which the deployment account setup makes cannot read on its own, and that account already holds sudo without a password.

sitesolide status reads what the machine runs, one line per unit under each project. A worker reads as any service, active with its memory. A scheduled service shows its schedule, when it last ran and how that run ended, and when it runs next. Between two runs it is inactive, its normal state and no outage, or failed after a run that failed, until the next one. The main unit of a project whose services are all scheduled, its anchor, is active and holds no memory, since it runs nothing. See manifest.md for the kinds of service.

deploy --dry-run --compare runs the build, as --build does, then measures on the server what this deployment would change there, and changes nothing: the files its rsync would send or delete (compared by content, a time or a mode alone does not count), the deposited manifest, a missing unit, a Caddy block that differs. A unit edited on the machine is reported and never counted, deploy leaving it as it is without --force; a block edited there is refused, as deploy refuses it. upgrade asks exactly this of the dashboard and the portal.

sitesolide upgrade runs from anywhere, against the machine the configuration names, as the account that deploys:

$ sitesolide upgrade --dry-run
-> upgrade of deploy@203.0.113.10, zone example.com, to the code of release v0.4.0, dry run: nothing is changed on the machine
up to date   Caddy's drop-in
up to date   backups
up to date   egress proxy
out of date  steward             differs: steward.js; would run bin/deploy-steward.sh
   dashboard/: sitesolide deploy --dry-run --compare, which builds it first
out of date  dashboard           differs: /srv/sites/dashboard/public: 18 entries to send or delete; would run sitesolide deploy in dashboard/
...
missing      installer           not installed: sitesolide setup installs it

Each component is checked first, reading only: what setup checks for it, and the fingerprint of what would be installed against what is, the SHA-256 the deploy script itself verifies after installing. Only a component that differs is redeployed, through the script or the sitesolide deploy setup runs for it, in the order upgrading.md gives. A component the machine does not carry is reported missing, and sitesolide setup installs it. A failure stops at its component, quotes the end of what its script printed and the command that shows more; the same command again resumes there. A second run right after a successful one finds everything up to date and changes nothing. It never reads nor rotates a secret, and never touches Caddy but through bin/deploy-caddy.sh and systemctl. See upgrading.md.

A project's protections live in its systemd unit, generated from its manifest: the ports it may listen on, the system calls it may make. A unit is written by sitesolide deploy, which also sends the folder's code, and upgrade never touches a project's unit: after a release that changes what the units carry, every app keeps the previous one until it is deployed again, and an app whose code on the workstation is not what runs in production cannot be, without shipping that code. sitesolide refresh writes the units alone, from the manifest the last deployment left on the machine, over the owner's SSH, from any folder:

$ sitesolide refresh --all --dry-run
-> refresh of deploy@203.0.113.10, zone example.com: every project's units, generated again from the manifest the server carries; no code is sent; dry run, nothing is changed
-> blog, static: no unit to refresh
-> notes, service
   unchanged  /etc/systemd/system/notes.service
-> shop, 2 services
   to write   /etc/systemd/system/shop.service, written by an earlier release
   unchanged  /etc/systemd/system/shop.api.service
...
-> would be refreshed: shop
   already current: notes
   static, no unit to refresh: blog

$ sitesolide refresh --all

Run it after an upgrade whose notes say the generated units changed, to give every app the new protections without redeploying its code; refresh <slug> does one project.

Each project's manifest is read on the machine, /srv/sites/<slug>/sitesolide.json, and validated as deploy validates one; one that does not read, or that this release refuses, refuses its project, saying why, and nothing is changed for it. Its units are generated as deploy generates them, the zone and the contact from the configuration, and each one is decided as deploy decides it without --force: a current unit is left alone, a missing one or one an earlier release wrote is written, root's, 0644, and one edited by hand is kept and reported, never replaced: refresh has no --force, and sitesolide deploy --force in the project's folder is what switches such a unit. A main unit edited by hand, in a project of several services, keeps all of them, as deploy refuses that case. No unit is ever removed, a service no longer declared included.

When a unit is to be written, the project takes the Caddy lock, the one deploy holds from its first deposit to the end of its restart, and reads its manifest and units again under it: a deployment or the dashboard that changed them meanwhile stops the run there, nothing written. Then daemon-reload, and the project's services restarted and checked exactly as deploy does it: every unit but a scheduled service, which its timer starts; each active; the site over HTTPS; every service that runs for good still active ten seconds later, never restarted by systemd meanwhile. A project with nothing to write takes no lock and restarts nothing, and a second run right after the first changes nothing on the machine. A static project has nothing to refresh.

--all takes the projects one at a time, in the order of their names, and stops at the first whose restart or check fails: one site down at most, its units written and its services restarted under them, the projects after it left as they are. The refusal names the project, what was written for it, what the run did before it and what it never reached. A service that held under its earlier unit and falls under the new one hit one of its protections: its journal, sitesolide logs in its folder, says which. The run ends with a summary: refreshed, already current, kept (edited by hand), refused, static. --dry-run reads the manifests and the units, and prints for each project the units it would write, with their text, and touches nothing.

With --json, the result carries dryRun, all, projects (each slug, state: refreshed, outdated in a dry run, current, kept, refused or static; write, the units written or that a dry run would write; current; kept; reasons, why it was refused; url and status, what the site answered), and the slugs by state: refreshed, outdated, current, kept, refused, static. It is the owner's command, like upgrade: refused with a token (needs-ssh), and not an MCP tool.

Who may open a project, and who may do what on it, is one list per project, its people with access, which the steward keeps: each entry an email or a whole domain, @acme.com, with a role, Can open (can-open), Viewer, Developer or Admin, each including the ones below it. access.md says what each role does. sitesolide share, in the project's folder, reads the list and changes it; the dashboard's Access section is the other way.

$ sitesolide share
-> access to notes, https://notes.example.com/, over SSH, as the owner
   general access: Restricted: visitors are asked to sign in.
   people with access:
     alice@acme.com      Developer
     @acme.com           Can open
     client@example.org  Can open, password access until 2026-11-07 09:00 UTC
   Also open it without being listed: the owner, and owner@acme.com, set on the server to open every site.

$ sitesolide share bob@acme.com carol@acme.com --role developer
-> access to notes, https://notes.example.com/, over SSH, as the owner
-> bob@acme.com: Developer on notes
-> carol@acme.com: Developer on notes
   ...
   send: Open https://notes.example.com/ and sign in with your Google account.

$ sitesolide share dana@example.net --expires 30d
-> access to notes, https://notes.example.com/, over SSH, as the owner
-> dana@example.net: Can open, password access until 2026-11-07 09:00 UTC on notes
   password for dana@example.net: Xith-G4r4-nRJs-uDMV
   shown once: send it to them yourself, with the address; the machine keeps only its hash
   ...

$ sitesolide share --remove @acme.com
-> access to notes, https://notes.example.com/, over SSH, as the owner
-> @acme.com no longer has access to notes: refused from their next request
   ...

With no argument it shows the project's general access, its people with access, each with their role and password access with its expiry, and who else opens it without being on the list. Emails and @domains are given access, Can open unless --role names another; someone already on the list gets the role named, raised or lowered, and someone who has it already is left as they are. A domain is Can open only, and only once signing in with a company account is set up. Someone outside the company's domains, or anyone when that is not set up, is Can open only, with password access: the machine draws a password for them, which the command prints once, and the access lasts what --expires says, 7 days by default. --remove takes access away, from the person's next request. Several people are handled one by one: a refusal stops before the next, and says who was already given access.

Nothing is sent to anyone: after giving someone access with an account, the command prints the line to send, send:, and for password access the password, once. Two things are never done here: general access, from the dashboard's Access section, and the preview code, sitesolide lock. --domain and --only-admins, from before, are refused with a pointer: a domain is written @acme.com.

With --json, the result carries slug, url, general (public, restricted, code, or null for a project not deployed), entries, signIn (configured, allowedDomains), message (the line to send, null until signing in with a company account is set up), changed, and after a change changes, each { who, change, role }, change being add, role, none or remove, and password for password access just given:

$ sitesolide share dana@example.net --json
...
{"type":"result","ok":true,"command":"share","slug":"notes","url":"https://notes.example.com/","general":"restricted","entries":[...,{"who":"dana@example.net","kind":"password","role":"can-open","by":"owner","createdAt":1791450000000,"updatedAt":1791450000000,"password":{"expiresAt":1792054800000,"expired":false}}],"signIn":{"configured":true,"allowedDomains":["acme.com"]},"message":"Open https://notes.example.com/ and sign in with your Google account.","changed":true,"changes":[{"who":"dana@example.net","change":"add","role":"can-open","password":"Xith-G4r4-nRJs-uDMV"}]}

The owner's share runs over SSH: root on the machine asks the steward on its owner socket, /run/sitesolide-steward-owner/owner.sock, which only root opens, the body on standard input so that no address goes through a shell. The steward judges the change by its access rules, writes the portal's projection and its registry, and records it in its journal under owner. Root is the owner: no unlock is asked. It touches nothing else, Caddy least of all. With a token, the command goes through the dashboard to the steward, and gives Can open alone: see access.md. While the portal on the machine still decides from its own tables, halfway through an upgrade, the command warns of it: sitesolide upgrade deploys the portal that reads the registry.

Everyone with access, machine-wide, for the owner, from any folder: their roles per project, their password access, who may create projects, the domains, and the admin emails, which open every site as admin, set on the server in OIDC_ADMIN_EMAILS. The dashboard's People page is the other way. Roles are given per project, with sitesolide share; people grants the right to create projects.

$ sitesolide people
-> people of https://dashboard.example.com, over SSH, as the owner
   alice@acme.com      notes: Developer, shop: Viewer
   client@example.org  notes: Can open, password access until 2026-11-07 09:00 UTC
   you@acme.com        no project; every site, as admin: set on the server in OIDC_ADMIN_EMAILS
   @acme.com           Can open: blog, notes
   the company's domains: acme.com; anyone else gets password access

$ sitesolide people alice@acme.com --may-create
-> people of https://dashboard.example.com, over SSH, as the owner
-> alice@acme.com may create projects, Admin of each one they create: open https://dashboard.example.com and sign in with their company account
   alice@acme.com  notes: Developer, shop: Viewer; may create projects

--may-create grants the right to create projects to someone who signs in with a company account, whatever their roles, none included: they sign in to the dashboard, mint a token that may create projects, and become Admin of each project it creates. --no-create takes it back and leaves their roles; someone then left with no role above Can open no longer signs in to the dashboard, their sessions closed and their tokens revoked.

With --json, the listing's result carries people (each who, roles by project, can-open for Can open, create, passwords, each slug, expiresAt, expired, and admin), domains (each slug, domain), signIn and changed: false; --may-create and --no-create, email, create, roles, change (create or none) and changed.

Root on the machine asks the steward on its owner socket, as for share, and the steward records the change in its journal under owner. With a token, the command is refused before anything is sent: it is the owner's. See access.md for what each person sees, and dashboard/README.md for the machine's side.

A site's general access is Public, Restricted or Anyone with the code, chosen from its Access section in the dashboard, by its Admin or the owner. From the project's folder, over the owner's SSH, the code is set, replaced and dropped too:

sitesolide lock              # Anyone with the code, and the code; on a site that has one, the one in force
sitesolide lock --new-code   # a new code: the link already sent stops opening the site
sitesolide unlock            # Public again
sitesolide lock --status     # what the machine wants, installs and serves, without touching

lock and unlock take the same path as the dashboard, and no other: root on the machine asks the steward on its owner socket, which launches the gatekeeper. The gatekeeper draws the code on the machine, six characters without O, I, 0 or 1, writes the manifest, the codes file and the locks' fragment in one transaction, a restricted site losing its portal in the same one, validates, reloads Caddy with systemctl, checks over HTTPS that the door page answers without the code and the site with it, a replaced code no longer opening it, and puts everything back at the slightest failure. The command prints the code and the link that carries it, once, and the local sitesolide.json then says what the machine does, to commit. The steward records the change under owner, never the code. A site that serves its own domain closes there too, and the gatekeeper checks both addresses; the link then leads to the domain, where its visitors go.

$ sitesolide lock
-> general access of notes: Anyone with the code, through the steward and the gatekeeper
   code set: validated, reloaded, notes.example.com answers the door page's 401 and opens with its code, 13 other site(s) still answer

   notes opens with its code.

   Code   : K7M2PQ
   Link   : https://notes.example.com/?key=K7M2PQ

With --json, the result carries slug, access (code or public), code and url (null once Public), and manifestWritten.

sitesolide detect reads the folder and prints the manifest it implies: a Go module, a FastAPI or Flask app, a package.json app or generated site, a folder of files. It reads no machine and needs no configuration. --write writes the manifest, never over an existing one.

sitesolide deploy in such a folder prints the same manifest and stops; --yes writes it and deploys, unless the machine already serves a project of that name. An app whose manifest declares no port gets a free one from deploy, written back into sitesolide.json to be committed. See agents.md for what is recognised, and what is never decided for you: secrets and the network.

sitesolide machine orders the VM itself, before there is a configuration to read: it talks to the provider's API, never over SSH. See machine.md for what it creates, what it costs and what the token needs.

sitesolide machine create --provider hetzner --name <name>
                                order a VM, its firewall and your SSH key, ready for setup
   --type <type>                cx23 by default; a refusal lists what the location sells
   --location <location>        fsn1 by default
   --image <image>              debian-13 by default
   --backups                    the provider's daily backups, about 20 % on the price
   --ssh-key <path.pub>         ~/.ssh/id_ed25519.pub, id_ecdsa.pub or id_rsa.pub by default
sitesolide machine list --provider hetzner
                                the machines sitesolide created, their addresses and price
sitesolide machine destroy <name> --provider hetzner
                                delete the machine and its firewall, for good
   --confirm <name>             the name typed back, where no terminal can ask for it
   --delete-key                 delete the SSH key uploaded for it too
any machine command --token-stdin
                                read the token from standard input rather than HCLOUD_TOKEN

--json, on every one of them: one JSON event per line, see docs/agents.md
the token is never an option, where ps would show it: see docs/machine.md

create ends on the command that comes next, sitesolide setup root@<ipv4> --zone <your zone> --email <you>, once port 22 answers.

--json prints one JSON event per line on standard output, nothing else, and ends with a result or an error carrying a hint. sitesolide mcp serves detect, deploy, status, logs, share (and access, its read alone) and lock --status as tools to an MCP client; setup, upgrade and refresh, which change the machine itself or every app on it, are not tools. Both are described in agents.md.

A workstation with no server, but the dashboard's address and a token, never touches SSH: someone's own, or an agent's. See access.md.

sitesolide login --url <https://dashboard.zone>   keep the token, check it
   --token-stdin                                  read it from standard input
sitesolide deploy                                 build here, upload, follow the machine's log
sitesolide status                                 the projects this token may deploy
sitesolide logs [--follow]                        the journal of this folder's project
   --lines <n>                                    how many lines back, 50 by default, 500 at most
sitesolide share                                  this folder's project's general access and people with access
   <email|@domain>...                             give them Can open: people inside the company's domains, or one of them
   --remove <email|@domain>...                    take their Can open away
sitesolide secrets                                this folder's project's secret files and variable names, never a value
sitesolide secrets set <NAME>                     set a variable of <slug>.env, typed at a prompt that does not echo it
   --stdin                                        the value from standard input, one trailing line break dropped
sitesolide secrets remove <NAME>...               take variables out of <slug>.env
   --restart                                      with set or remove: then restart the service, and say whether it holds
sitesolide restart                                restart this folder's project's service, and say whether it holds

--json, on every one of them: one JSON event per line, see docs/agents.md
a secret's value never goes on the command line: with --json, secrets set needs --stdin
SITESOLIDE_API and SITESOLIDE_TOKEN, both in the environment, win over the files.

login asks for the token at a prompt that does not echo it, unless --token-stdin or SITESOLIDE_TOKEN gives it, and keeps it in ~/.config/sitesolide/secrets/team-token, 0600, the address it checked it against beside it, team-token-api, and the address in config.json under api. SITESOLIDE_API and SITESOLIDE_TOKEN in the environment win over both, for an agent's sandbox, the two together: the vault's token goes to the address login wrote beside it and to no other, and a SITESOLIDE_API or a config.json naming another is refused, unauthenticated, until sitesolide login --url checks the token there. A vault filled before login wrote the address sends its token to the api of the config.json beside it. A person's own token says so: login and status print whose roles bound it, and what it may do is the steward's reading of those roles at that moment. share gives Can open alone, and never password access, see access.md.

deploy sends the manifest first, so that a refusal arrives before the build, then a gzip-compressed tar holding app/ and public/, exactly what rsync would have sent, and follows the machine's log until the end; it exits non-zero when the deployment fails. A manifest with a start and no port gets one chosen on the machine.

secrets lists the secret files of the project in the folder, with their variable names and whether a restart is pending, and never a value: no route reads one back. secrets set NAME and secrets remove NAME... change <slug>.env, the one file a token changes, and only when the deployed sitesolide.json declares it; set creates the file when it is missing. All three, the listing included, need a token minted with Manage secrets.

The value never comes from the command line, where the shell's history and the process list would keep it: set asks for it at a prompt that does not echo it, or reads all of standard input with --stdin, one trailing line break dropped. --stdin reads a pipe: when standard input is the terminal, where the value would show as it is typed, it is refused, and leaving it out gives the prompt. At the prompt, Backspace, Ctrl-U and Ctrl-W edit the value, Enter ends it and Ctrl-C abandons it; an arrow key, Tab or any other control key is refused, and so is a paste of several lines, whose rest is read and dropped rather than left for the shell to run. Under --json nothing prompts, so set needs --stdin.

What the machine would refuse is refused before a request leaves, and the refusal never quotes what was typed: NAME=value, or anything after the name; a name that looks like a value, over 64 characters or starting as a service's key does (sk_, ghp_, xox, AKIA, sst_ and the like), with a reminder that the shell's history now holds it (a variable really named so is set from the dashboard); a value on several lines, with a null byte, not valid Unicode, or over 8 KiB, which belongs in a file read whole, from the dashboard; a value with a Unicode noncharacter, U+FDD0 to U+FDEF or the last two code points of a plane, for which systemd would read none of the file's variables. An empty value is refused too: an unset variable piped by mistake would empty the secret in service; remove takes a variable out.

When the connection drops once a change has left, the run says it may have been written, rather than that the dashboard is unreachable: sitesolide secrets shows the names, and the same command run again makes the same change once more, harmlessly.

sitesolide secrets                              # files and variable names
sitesolide secrets set API_KEY                  # typed at the prompt, no echo
openssl rand -hex 32 | sitesolide secrets set SESSION_SECRET --stdin --restart
sitesolide secrets remove OLD_KEY LEGACY_URL
sitesolide restart

--restart restarts the service once the change is written. restart, which any token that deploys the project may run, restarts its service and watches it for eight seconds: it exits non-zero unless the service holds. Over the owner's SSH, both point to the dashboard instead: Secrets manages the files there, and Restart service, in a project's Overview, restarts it. See access.md.

Any other option is refused before a request leaves, deploy --dry-run first: a dry run reads the machine over the owner's SSH access, which a token does not carry, and the control API has no route that judges without deploying. --json prints the same events as over SSH, and one final result or error.

The other commands need the owner's SSH access and say so. The owner, whose configuration has a server, keeps SSH for every command; --api makes one go through the dashboard instead, to see what a token's holder sees.

sitesolide backups lists the snapshots the machine keeps of the project's data folder, on the server and in the bucket, with the size of each and what it added to the repository when it was taken, and its last run. It reads and changes nothing: a restore is made from the dashboard's Backups section, which saves the current data first. See dashboard/src/backup/README.md.

A release of the CLI that closes holes found in review: what an agent, a token or a cloned folder could make deploy do. It changes how some deployments behave, listed below. The commands are yours to run, in this order. It came before sitesolide upgrade, which now does steps 2 and 3 on its own, only for what differs: see Upgrading the machine.

What changes for you.

BeforeNow
any valid slugcaddy, ssh, cron, nftables, www, every systemd-* and sitesolide-*... are refused, and so is a slug systemd already gives to a unit deploy did not write and that does not serve /srv/sites/<slug>, --force or not
deploy --dry-run ran the buildit shows the build and does not run it; --dry-run --build runs it
install ran in app/ as the deployment account, with sudoit runs as site-<slug>, in a transient unit with its service's walls: the network but not the loopback, only app/ writable, HOME in a throwaway /tmp, 1G and fifteen minutes at most
ports checked before the buildchecked again under the Caddy lock, before the manifest is deposited; 3022, 3026 and 3029 are refused to every project but the dashboard, the portal and analytics
.git sent with the code, public/ sent as it is.git and every .env* never leave, at any depth; the next deploy removes those an earlier one left in public/, and Caddy answers 404 for them
source could lead anywherefrom a manifest outside the sites repository of your configuration, only inside its own repository
a token's deploy --dry-run deployed for realrefused, as is every option the token's path does not carry; --json speaks events there too

1. The workstation. Pull, then bin/test.sh. Check that sitesolide deploy --dry-run in a project with a build prints [dry-run] build (...), not run, and that config.json names your sites repository under sites (sitesolide init --sites <path> otherwise): a manifest there keeps a source that climbs out, as mini-lab does.

2. What the machine runs from this repository, before redeploying any other site: they recognise the Caddy blocks the new generator writes, whose file server hides .git and .env*. Until they are updated, the dashboard refuses to change the general access of a site redeployed with the new CLI ("redeploy the site first"), and a token's deploy of it stops on edited-by-hand; nothing served changes.

cd dashboard && sitesolide deploy   # the dashboard itself, and its own block
bin/deploy-steward.sh               # policy.ts
bin/deploy-gatekeeper.sh            # bin/cli/fragment.ts
bin/deploy-installer.sh             # src/installer/, policy.ts, bin/cli/

Each script checks what it installs. Then the dashboard's Access section should switch a test site between Public and Restricted as before.

3. The Caddyfile, whose landing, wildcard and customer-domain blocks now hide .git and .env*:

bin/deploy-caddy.sh                 # validates with systemd's environment, reloads, restores on failure

Check: curl -s -o /dev/null -w '%{http_code}\n' https://<static-site>.<zone>/.git/config answers 404, and the site's home 200.

4. Every app, one by one, with sitesolide deploy. Its block is replaced without --force ("written by an earlier release, the current one replaces it"), and its install now runs as site-<slug>: the step reads -> dependencies (...), as site-<slug> in its service's walls. An install that wrote outside app/, needed root or a tool installed into HOME now fails with install failed, the code in place and the service not restarted: move that step into build, or install the tool on the machine. The platform's own manifests, the dashboard's and analytics' bun install --production, run in app/ and need nothing more.

5. Leftovers. A .env an earlier deploy put in app/ stays there, since an excluded file is protected from rsync's --delete, and Bun still loads it at start. Read them, then remove the stale ones:

ssh <server> "sudo find /srv/sites/*/app -maxdepth 3 -name '.env*' -not -path '*/node_modules/*'"

Rolling back. Check out the previous commit on the workstation, and run steps 2 and 3 from it. A site whose block was written by this release then reads, to the previous CLI, as a block that "no longer matches the manifest": sitesolide deploy --force in its folder puts the previous block back.