Getting started
Setup touches two places. Each command is tagged:
- 🖥️ On your server — the Linux VPS, over SSH, as root.
- 💻 On your machine — inside your project’s repo.
Install the binary
Section titled “Install the binary”On the server:
curl -fsSL https://statio.accentio.dev/install.sh | sudo shIt detects your OS/arch, downloads the binary from GitHub Releases, verifies the checksum, and
installs it to /usr/local/bin/statio. You also need Docker on the server and a Tailscale
account (the free plan is enough).
Step 0 · Tailscale (once, on the web)
Section titled “Step 0 · Tailscale (once, on the web)”Tailscale is the private channel CI uses to reach the agent — it replaces SSH, so the agent
never opens a public deploy port. It is not how your app is served (that’s your reverse proxy on
80/443).
Do these steps in order — an OAuth client can only own tags that already exist, so the tags come first.
1. Define the tags (Access Controls)
Section titled “1. Define the tags (Access Controls)”Open Access controls in the admin console and paste this ACL. It creates tag:agent (the
agent) and tag:ci (CI), and allows only tag:ci to reach the agent, on a single port:
{ "tagOwners": { "tag:agent": ["autogroup:admin", "tag:agent"], "tag:ci": ["autogroup:admin", "tag:ci"] }, "acls": [ { "action": "accept", "src": ["tag:ci"], "dst": ["tag:agent:443"] } ], "ssh": []}2. Create the two OAuth clients (with those tags)
Section titled “2. Create the two OAuth clients (with those tags)”Create two OAuth clients at Settings → OAuth clients → Generate OAuth client (newer consoles: Trust credentials → New credential) — one for the agent, one for CI. For each, pick Custom scopes (every scope Write), and when Tailscale prompts for tags, assign the one from this table:
| OAuth client | Assign tag | Scopes (Write) | Its id + secret go to |
|---|---|---|---|
| Agent | tag:agent | auth_keys + devices:core | statio init server (Part A) |
| CI | tag:ci | auth_keys | the STATIO_TS_OAUTH_CLIENT_ID + STATIO_TS_OAUTH_SECRET GitHub secrets (Part B) |
Each client gives you an id and a secret. For CI’s client, those map to the two GitHub secrets exactly like this (you set them in Part B):
| Tailscale value | Looks like | GitHub secret |
|---|---|---|
| CI client id | k123ABC... (short, not sensitive) | STATIO_TS_OAUTH_CLIENT_ID |
| CI client secret | tskey-client-… | STATIO_TS_OAUTH_SECRET |
The agent client’s id + secret aren’t GitHub secrets — you paste them straight into statio init server.
What each scope does
auth_keys(Keys → Auth Keys → Write) — lets the client mint the node key it joins the tailnet with. Both clients need it.devices:core(Devices → Core → Write) — lets the agent register and manage itself as a persistent node. Only the agent needs it. (Enabling it is also what makes Tailscale prompt you to pick the tag.)
The same CI pair works for every repo — set it once org-wide and every repo inherits it.
These are the only manual Tailscale steps.
Part A — On your server 🖥️
Section titled “Part A — On your server 🖥️”Two steps: init server brings up the agent, and app add accepts each app you’ll deploy.
A1 · Configure the agent 🖥️
Section titled “A1 · Configure the agent 🖥️”sudo statio init serverIt asks only for the server name and the agent’s Tailscale OAuth client (the tag:agent id +
secret from Step 0) — no repo here. It writes the agent config and enables and starts the
statio-agent service, which joins the tailnet with that client. It no longer mints or prints
any key:
Server name › statio OAuth client ID › k123ABC... OAuth client secret › ••••••••••••••••
✓ agent configured and started — joining the tailnet as `tag:agent`CI doesn’t use this client at all. It joins the tailnet with its own tag:ci OAuth client — the
two STATIO_TS_OAUTH_CLIENT_ID / STATIO_TS_OAUTH_SECRET secrets you set in Part B. The same
pair works for every repo (set it once org-wide and every repo inherits it, or per repo).
A2 · Accept an app 🖥️
Section titled “A2 · Accept an app 🖥️”You authorize each app — and which GitHub repo may deploy it — with statio app add. Apps can come
from different repos and even different organizations; each pins its own signer.
sudo statio app add apiThe wizard asks for the GitHub repo first and detects the rest from it:
App name › api This app's GitHub repo › accentiostudios/api # detected: PUBLIC, default branch main Workflow file / Branch › deploy.yml / main # branch pre-filled from the repo Image on GHCR (this repo)? › Yes → ghcr.io/accentiostudios/api # inferred; needn't exist yet Extra containers (DB/…)? › no # single-container app → skip Expose a public domain? › noAfter you enter the repo, app add looks it up: public repos are read from the GitHub API with
no auth; private ones need gh installed and logged in on the server (else it just asks you to
type the branch by hand). Since app add runs under sudo, it calls gh as the user you sudo from
($SUDO_USER) — root has no gh login — so log in there as a normal user, not as root. From that
it pre-fills the default branch and, if you say the image lives
on GHCR under the same repo, infers ghcr.io/<owner>/<repo> (lowercased) so you don’t paste a URL —
or pick “No” to paste a Docker Hub / other registry path.
That repo + workflow + branch becomes this app’s cosign signing identity:
https://github.com/<owner>/<repo>/.github/workflows/<file>@refs/heads/<branch> — matched exactly
(see Footguns of the signing identity). Run
app add again for a second app from another repo/org.
A signed deploy can only target an app you already accepted — it can never stand one up, and it can only deploy what its repo signed (full reasoning: per-app signers).
The non-interactive form, for scripts:
sudo statio app add api --image ghcr.io/accentiostudios/api \ --repo accentiostudios/api --branch main \ --proxy-domain-suffix example.com --dns-domain-suffix example.comA3 · Verify the agent 🖥️
Section titled “A3 · Verify the agent 🖥️”init server already enabled and started the statio-agent service. Check everything with one
command — statio doctor:
sudo statio doctor # version, docker + login, agent config, state dir, the service…sudo statio doctor --fix # and fix what it safely can (e.g. a missing state dir, restart)Run it with sudo on the server: the config (0600 root), the secret files and the service status
all need root. Without sudo it still checks everything it can and tells you to re-run
with sudo for the rest.
Part B — In your repo 💻
Section titled “Part B — In your repo 💻”This part runs on your machine, inside your project’s repo — not on the server.
B1 · Prepare the repo 💻
Section titled “B1 · Prepare the repo 💻”statio init repoIn your repo, this:
- creates
statio.yamlif it doesn’t exist (your app’s config), - detects your repo and prints the exact signing identity to use in
statio app add(Part A), - checks whether you already have CI: if you have a workflow, it gives you a snippet to paste
(it never touches your file); if you don’t, it offers to generate a
.github/workflows/deploy.yml.
statio.yaml describes your app — it’s the source of truth for the deploy:
services: - name: api # your app: no `image:` → your signed image is injected ports: [3000] # the port your app LISTENS on (statio assigns the host port, # published only on 127.0.0.1) env: [DATABASE_URL] # the NAME only; the value comes from CI env_inline: { NODE_ENV: production } health: { path: /health }proxy: { domain: api.example.com } # optional — the agent computes the forward target itselfdns: { domain: api.example.com }One step does the whole pipeline — build, push, sign, deploy. It’s what statio init repo prints:
permissions: id-token: write # REQUIRED: cosign signs the image + payload (keyless OIDC) packages: write # push the image to GHCR contents: read
- uses: actions/checkout@v4- uses: accentiostudios/statio@v1 with: target: statio.your-tailnet.ts.net # the agent's hostname (= signed audience) service: api # must be accepted on the server (statio app add) image: ghcr.io/accentiostudios/api # the action builds+pushes here; must match statio app add --image env: | # one KEY=${{ secrets.KEY }} per env in your statio.yaml DATABASE_URL=${{ secrets.DATABASE_URL }} ts-oauth-client-id: ${{ secrets.STATIO_TS_OAUTH_CLIENT_ID }} # CI's tag:ci OAuth client ts-oauth-secret: ${{ secrets.STATIO_TS_OAUTH_SECRET }}The action builds your Dockerfile, pushes the image to GHCR, cosign-signs it, signs the payload
and deploys — no separate build-push or cosign steps. Already build the image yourself? Pass
digest: ${{ steps.build.outputs.digest }} and it skips building. Before deploying it runs a
read-only preflight against the agent (port availability, required env, proxy/DNS readiness) so
a misconfiguration fails the job fast without touching the server. statio never modifies your
workflow: if you already have one, you paste the step; if not, it generates the full deploy.yml.
The Action inputs are in the reference.
B2 · Configure the secrets 💻
Section titled “B2 · Configure the secrets 💻”From your machine, towards GitHub:
gh secret set STATIO_TS_OAUTH_CLIENT_ID --body '<CI's tag:ci OAuth client id>'gh secret set STATIO_TS_OAUTH_SECRET --body '<CI's tag:ci OAuth client secret>'gh secret set DATABASE_URL --body 'postgresql://app:...@db:5432/appdb'STATIO_TS_OAUTH_CLIENT_ID and STATIO_TS_OAUTH_SECRET are the same pair for every repo — set
them once org-wide (--org … --visibility all) and every repo inherits them, or set them per repo.
DATABASE_URL and any other app secret follow the rule: statio.yaml declares the names; the
Action’s env: block gives them their value from ${{ secrets.* }}. Anything non-secret goes in
env_inline.
B3 · Deploy 💻
Section titled “B3 · Deploy 💻”git pushCI builds and signs the image, signs the payload (the same keyless identity), and sends the
envelope. The agent verifies it, pulls the image, generates the compose from your statio.yaml,
and recreates the containers. Per-stage status shows in the Action logs; history is in
statio logs api.
Next steps
Section titled “Next steps”- Add a domain — reverse proxy + DNS.
- Environment variables — how secrets flow from GitHub to the container.
- Rollback — manual and automatic.
- GitHub Action reference and CLI reference.