# Secrets API keys, tokens, password hashes: what must never enter git. **The machine holds them, and nothing else does but its backups.** `/etc/sitesolide` on the VM is the vault, and systemd reads each project's files from it with `EnvironmentFile=` when the service starts. The dashboard manages every file there, from its *Secrets* section: you read a value, set or remove a variable, create a declared file that is missing, restore the previous version, and restart the service with a verdict. A token minted with *Manage secrets* sets and removes the variables of `.env` from a workstation or an agent's sandbox, `sitesolide secrets set NAME`, and never reads one back: see [access.md](access.md#secrets-with-a-token). The backups keep a copy, so that a lost machine does not take its secrets along: every hour, `/etc/sitesolide` goes into the server's restic repository with the machine's own state, then to the bucket if one is configured, encrypted by restic before it leaves. No page lists or restores that copy; its files are put back by hand (dashboard/src/backup/README.md, "The machine's own state"). Whoever reads `dashboard-backup.env`, the owner from the *Secrets* page, holds the passphrase and the bucket's keys, and so every file of that copy, the password hashes and the DNS token the page itself never shows included. No copy of production waits on your workstation, in a repository or beside it. `sitesolide deploy` never pushes a secret: it checks that each file the manifest declares is on the machine, and when one is not, it stops and names the dashboard where to create it. By then the manifest is deposited, so the dashboard already knows the file belongs to the site. **A manifest declares the project's own files alone.** The unit reads every file it declares as root, whoever owns it: a cloned repository deployed over SSH that declared `dashboard-backup.env` would hand its code the backups' keys, one that declared `cms.env` the secrets of cms. So `deploy` refuses, before anything is pushed, a file that is not `.env` or `-`, one of the machine's own, one a more specific site deployed there holds, and one already on the machine that is not a plain file of `site-`, reading its owner without opening it. A token's deployment is held to `.env` alone, and the installer refuses the same files, `secret-taken`. `sitesolide remove` deletes only the files that are the project's by that rule, and says which it kept. ## Who reads and who writes | Who | Their projects' files | The machine's own | |---|---|---| | The owner, with the dashboard's password | read, write, create, restore, change a password hash | the same: `dashboard.env`, `portal.env`, `dashboard-monitor.env`, `dashboard-backup.env` | | An Admin of the project | read, write, create, restore | nothing | | A Developer of the project | write without reading back: set, replace or remove a variable, replace a file read whole, create a declared file. No value, no size, no previous version ever comes back to them | nothing | | A token with *Manage secrets*, a person's token bounded by the person's role, whoever minted it | list the files and variable names; set, replace and remove the variables of `.env` alone, and create it, when the deployed manifest declares it. Never a value, a size or a previous version back, whoever holds it, and no unlock asked | nothing | | A Viewer, or someone who can only open the site | nothing: the Secrets section is not theirs | nothing | The steward decides every one of these, as root, on its own access registry: a request for a value from a Developer is refused before any file is opened, and a compromised dashboard, which relays it, cannot make it pass. Nobody but the owner ever changes a password hash, which takes the dashboard's own password, nor touches a file of the platform's projects. A Developer or an Admin unlocks by signing in again with the identity provider, never with a password, for ten minutes: see [access.md](access.md#people-with-access-beside-tokens) and [dashboard/README.md](../dashboard/README.md#access). ## Where a secret lands, and to whom it belongs From its name and its site, with no file to keep up to date: | | | |---|---| | Path | `/etc/sitesolide/` | | Owner | `site-`, the account the project's service runs as | | Mode | `0600` for an environment file, `0400` for a file read whole, `0444` for a `.pub` | A file belongs to a site by its name: `.env`, `-`, or `-secrets/` in a single-level subdirectory. When two sites could claim a name, `cms-tool.env` with both `cms` and `cms-tool` deployed, the most specific one wins. The machine's own files, `dashboard.env`, `dashboard-monitor.env` and `dashboard-backup.env`, are the dashboard's alone, whatever site their name happens to start with: a project deployed over SSH as `dashboard-backup` would otherwise list the backups' keys in its own *Secrets*, for its Admins to read. The landing is served from the directory named after the zone, but its files and its account carry the `landing` label: `landing-mail.env` is its. The steward, the root daemon that does the writing, brings two sources together: - the `secrets` of an app's `sitesolide.json`: these can be created from the dashboard even before they exist; - the files already present in `/etc/sitesolide` that carry a site's name: that is how a site no manifest describes, the landing or a service made by hand, keeps its files in the dashboard. **A file no manifest declares is managed once present, and never created from the dashboard.** Otherwise a compromised dashboard could lay an environment file down for a service that does not expect one yet. To add such a file, create it empty on the machine, then fill it from the dashboard: ```bash ssh you@your-machine 'sudo install -m 600 -o site-landing -g site-landing /dev/null /etc/sitesolide/landing-mail.env' ``` The owner and mode of a present file are compared exactly: more open, it is no longer a secret; more closed, its service would stop reading it. Either way it is listed as unmanaged, with the command that repairs it. **A file created from the dashboard, or by a token, starts with no previous version.** The steward drops whatever it kept under that name before it creates the file: a site removed and created again under the same name may be given its predecessor's uid, `useradd` handing the freed one out again, and a restore would otherwise hand it its predecessor's secrets. **A value systemd refuses never lands.** Besides one line, no null byte and valid Unicode, a value may hold no Unicode noncharacter, U+FDD0 to U+FDEF or the last two code points of a plane: systemd reads a file with one as invalid, and starts the service with none of its variables, the other lines' included. ## Three guard rails | Rule | Files | Why | |---|---|---| | **write-only**: replaced, never read back, size not shown | files read whole and closed to other accounts | a private key never comes back to the screen, and its size would already give away its algorithm | | **Change password only**: never read, never set by hand, never restored, the old one erased | any `PASSWORD_HASH`, in any file | a hash is cracked offline, and restoring would revalidate the password you changed because it had leaked | | **hash only**: no other variable | `dashboard.env`, `portal.env` | one more variable there would change what the service does: a socket path would send the next password typed to another socket | **What belongs to no project is not the dashboard's**: `cloudflare.env`, the token Caddy reads for its certificates, lives in `/etc/caddy` and is changed on the machine. Nor is the key people sign in to the dashboard with: the steward draws it and lays its private half in `/etc/sitesolide-portal/`, where only the portal's account reads it and the Secrets section never shows it, see [dashboard/README.md](../dashboard/README.md#the-key-pair). No secret is shared, and that is the rule: the shared service `api/` sends no mail and makes no call. Each file carries its project's name, so that each project has its own credentials, under its own account, in the same directory without ambiguity. ## The dashboard's own password `dashboard.env` belongs to root, `0600`, not to `site-dashboard` like the others. The dashboard's service still receives it, since `EnvironmentFile=` is read by PID 1 as root before the service takes its identity. In exchange, a compromised dashboard cannot rewrite the hash that unlocks the secrets. `dashboard-backup.env`, the bucket's settings and keys, is root's `0600` too, handed by PID 1 to the backup run, which reads there the bucket, the retention, the disk reserve and nothing that moves a program or a folder: whoever holds the dashboard's unlock writes it, and is not root on the machine. See [dashboard/src/backup/README.md](../dashboard/src/backup/README.md#setting-it-up). `dashboard-monitor.env`, the monitor's alerting addresses, is root's `0600` as well, and listed under the dashboard's site. PID 1 hands it to the monitor; the dashboard's service never reads it, so a compromised dashboard cannot learn the heartbeat's address without an unlocked session and ping it to hide that the machine is down. Create it empty once, then fill it from the dashboard: see [monitor/README.md](../monitor/README.md#alerting-healthchecksio-in-five-minutes). It is the one secret the dashboard cannot create for itself, being what opens it. On a machine that has none yet: ```bash bin/dashboard-password.sh ``` It draws a password, shows it once on your terminal, and puts its hash on the machine straight from memory: nothing is written on the way. Store the password before closing the terminal. Once the dashboard runs, **its password changes from the dashboard**, through *Change password* in the dashboard site's own *Secrets*. If it is lost, `bin/dashboard-password.sh --replace`. ## What your workstation keeps `~/.config/sitesolide/secrets/` holds what **your workstation itself** presents to reach production, and only that: the API token a command-line tool sends to one of your services, for instance. `sitesolide run -- `, from a project's folder, loads the files its manifest declares from there and runs the command: ```bash cd my-tool && sitesolide run -- bun scripts/sync.ts ``` It is not a copy of production. A value only the machine uses stays on the machine, and one the workstation keeps has two copies to hold equal: change it in the dashboard, then in that file. The folder is outside every repository, beside the configuration file, so no `git add` can ever publish it. Claude Code is denied reading and writing there, see `.claude/settings.json`. ## The tokens that create the machine The Hetzner token `sitesolide machine` uses and the Cloudflare token `sitesolide setup` uses are not the machine's secrets but yours, and sitesolide keeps neither on your workstation: each is read from an environment variable or from standard input for the length of one command, never from an option, which the shell's history and `ps` would show. Keep them in your password manager. The Cloudflare token also lives on the machine, in `/etc/caddy/cloudflare.env`, root:caddy 0640, where Caddy reads it for the wildcard certificate: setup lays it there. Restrict it to the one zone, `Zone / Zone / Read` and `Zone / DNS / Edit`, so that the machine holds no more than it needs. See [machine.md](machine.md) and [setup.md](setup.md). Up to 0.2 both lived in `~/.config/sitesolide/terraform/terraform.tfvars`; [upgrading.md](upgrading.md) says what to do with that folder.