Deploy your first app
By the end of this tutorial you push a commit and your app runs on your own server, in a container, behind a signature your CI produced. It takes about twenty minutes.
You will set up a Tailscale network, install statio on a server, start its agent, accept one app, prepare your repo, and push.
Before you start
Section titled “Before you start”You need four things:
- A Linux server you can reach over SSH as root, with Docker installed.
- A Tailscale account. The free plan covers this tutorial.
- A GitHub repo with a
Dockerfilethat builds your app. - The
ghCLI on your machine, logged in.
Your app must listen on a port and answer on a health path. This tutorial uses port 3000 and
/health. Change both to match your app.
Every command below is tagged:
- 🖥️ runs on your server, over SSH, as root.
- 💻 runs on your machine, inside your repo.
Step 1. Create the Tailscale tags and clients
Section titled “Step 1. Create the Tailscale tags and clients”CI reaches the agent over a private Tailscale network instead of SSH. Set that up once, in the Tailscale admin console.
Open Access controls and replace the policy with this:
{ "tagOwners": { "tag:agent": ["autogroup:admin", "tag:agent"], "tag:ci": ["autogroup:admin", "tag:ci"] }, "acls": [ { "action": "accept", "src": ["tag:ci"], "dst": ["tag:agent:443"] } ], "ssh": []}Save it. Each tag lists itself as its own owner, which is what lets an OAuth client register a device with that tag.
Now open Settings → OAuth clients → Generate OAuth client and create two clients, in this order:
- Name it
statio-agent. Pick Custom scopes, check Write onauth_keys(Keys → Auth Keys) and ondevices:core(Devices → Core). When Tailscale asks for tags, picktag:agent. Copy the id and the secret it shows you. You paste them in step 3. - Name it
statio-ci. Pick Custom scopes, check Write onauth_keysonly. Assigntag:ci. Copy the id and the secret. You paste them in step 6.
The secret appears once. Keep both pairs in your password manager before you close the page.
Step 2. Install statio on the server 🖥️
Section titled “Step 2. Install statio on the server 🖥️”curl -fsSL https://statio.accentio.dev/install.sh | sudo shThe installer detects your OS and architecture, downloads the binary from GitHub Releases, checks
its checksum, and writes it to /usr/local/bin/statio. Confirm it:
statio versionStep 3. Start the agent 🖥️
Section titled “Step 3. Start the agent 🖥️”sudo statio init serverThe wizard asks for a server name and for the agent’s Tailscale OAuth client. Paste the statio-agent
id and secret from step 1:
Server name › statio OAuth client ID › k123ABC... OAuth client secret › ••••••••••••••••
✓ agent configured and started ✓ Agent address (use this as the CI 'target'): statio.your-tailnet.ts.netWrite down that address. Step 5 asks for it.
Check the agent came up:
sudo statio doctorEvery line reports ok. If one does not, run sudo statio doctor --fix and read what it prints.
Step 4. Accept your app 🖥️
Section titled “Step 4. Accept your app 🖥️”The agent deploys an app only after you accept it by name:
sudo statio app add apiAnswer the wizard:
App name › api This app's GitHub repo › your-org/api Workflow file / Branch › deploy.yml / main Image on GHCR (this repo)? › Yes Extra containers (DB/…)? › no Expose a public domain? › noSay no to the public domain. You add one later, in Add a domain.
The image does not exist yet. You are telling the agent where CI will push it.
Step 5. Prepare your repo 💻
Section titled “Step 5. Prepare your repo 💻”Change into your project on your machine and run:
statio init repoIt writes statio.yaml and generates .github/workflows/deploy.yml. Open statio.yaml and make it
match your app:
services: - name: api ports: [3000] env: [DATABASE_URL] env_inline: { NODE_ENV: production } health: { path: /health }ports is the port your app listens on inside the container. env lists key names, never values.
Open .github/workflows/deploy.yml and set target to the agent address from step 3:
- uses: accentiostudios/statio@v1 with: target: statio.your-tailnet.ts.net service: api image: ghcr.io/your-org/api ts-oauth-client-id: ${{ secrets.STATIO_TS_OAUTH_CLIENT_ID }} ts-oauth-secret: ${{ secrets.STATIO_TS_OAUTH_SECRET }} env: | DATABASE_URL=${{ secrets.DATABASE_URL }}Step 6. Set the GitHub secrets 💻
Section titled “Step 6. Set the GitHub secrets 💻”Paste the statio-ci id and secret from step 1, plus one secret per key your statio.yaml declares:
gh secret set STATIO_TS_OAUTH_CLIENT_ID --body 'k456DEF...'gh secret set STATIO_TS_OAUTH_SECRET --body 'tskey-client-...'gh secret set DATABASE_URL --body 'postgresql://app:...@db:5432/appdb'Step 7. Push 💻
Section titled “Step 7. Push 💻”git add statio.yaml .github/workflows/deploy.ymlgit commit -m "add statio deploy"git pushOpen the Actions tab and watch the run. The step prints one line per stage:
✓ admit ✓ verify ✓ pull ✓ env ✓ recreate ✓ health ✓ persistsuccessYour app is running. Confirm it from the server:
sudo docker psstatio logs apiWhat you built
Section titled “What you built”CI builds your image, pushes it to GHCR, and signs it with the run’s own identity. It then signs a
deploy payload and sends it to your agent over the Tailscale network. The agent checks the signature
against the repo you named in step 4, pulls the image by digest, writes the env to RAM, generates a
compose file, starts the container, and probes /health. A failed probe restores the previous
version.
Your server opens no deploy port and accepts no SSH from CI.
- Add a domain so the public reaches your app over HTTPS.
- Pass secrets from GitHub to the container.
- Roll back to an earlier digest.
- Why Tailscale instead of SSH explains the choice you just used.