Skip to content

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.

On the server:

Terminal window
curl -fsSL https://statio.accentio.dev/install.sh | sudo sh

It 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).

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.

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 clientAssign tagScopes (Write)Its id + secret go to
Agenttag:agentauth_keys + devices:corestatio init server (Part A)
CItag:ciauth_keysthe 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 valueLooks likeGitHub secret
CI client idk123ABC... (short, not sensitive)STATIO_TS_OAUTH_CLIENT_ID
CI client secrettskey-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.

Two steps: init server brings up the agent, and app add accepts each app you’ll deploy.

Terminal window
sudo statio init server

It 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).

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.

Terminal window
sudo statio app add api

The 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? › no

After 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:

Terminal window
sudo statio app add api --image ghcr.io/accentiostudios/api \
--repo accentiostudios/api --branch main \
--proxy-domain-suffix example.com --dns-domain-suffix example.com

init server already enabled and started the statio-agent service. Check everything with one command — statio doctor:

Terminal window
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.

This part runs on your machine, inside your project’s repo — not on the server.

Terminal window
statio init repo

In your repo, this:

  • creates statio.yaml if 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 itself
dns: { 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.

From your machine, towards GitHub:

Terminal window
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.

Terminal window
git push

CI 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.