Skip to main content
Photo of Anton ShubinAnton ShubinContact

Self-hosting

rostok: scaffold a self-hosted homelab from a curated service catalog

rostok is a CLI that turns an empty folder into a Git repo for your servers: a catalog of 50 stacks, encrypted secrets and a deploy over SSH.

Anton ShubinUpdated 7 min read

The tool: rostokCode: github.com/spy4x/rostok (opens in a new tab)

TL;DR

  • rostok is a CLI: run it in an empty folder, answer a few questions, pick services from its catalog, and you get a Git repo with one folder per server.
  • Once you generate a key, every .env value is encrypted on its own into .env.age, so the repo is safe to push; the key stays on your machine.
  • rostok deploy copies a server's files over SSH and starts them with Docker Compose. The server needs only SSH, rsync and Docker.
  • Install with deno install -g -A -n rostok jsr:@rostok/cli. Release 1.21.0 ships 50 stacks.
Picking services from the catalog in the wizard

Every homelab I stood up started the same way: a folder, a README, and a pile of compose.yml files copied from the last one. The secrets lived somewhere else, and the note on where they lived drifted from reality right away.

rostok is the CLI I built to stop doing that. It grew out of the repository that runs my own servers, and I still deploy my services with it. This post is for anyone who runs Docker on one to a few machines (an old PC, a homelab, or a small company replacing SaaS) and wants the whole setup in a Git repo. I'll show what it does, then walk through it from an empty folder to a service live on HTTPS.

You run rostok in an empty folder and answer a few questions: the server's name, its SSH address, your domain and email. The wizard turns that into a Git repo with one folder per server, adds the stacks you pick from the catalog and fills in their settings. rostok deploy home then copies the files to the server over SSH and starts them with Docker Compose.

  • Plain files in Git. Each server is a folder with a config.json and a .env. No database, no daemon, no web UI.
  • Secrets you can commit. Every value is encrypted on its own into .env.age with age, inside the CLI, with no extra binary to install.
  • No agent on the server. The server needs SSH, rsync and Docker, nothing else.

The name is the Russian word for "sprout".

Running a service yourself is rarely the hard part. The plumbing around it is:

  • Which services fit together, and which ones need a reverse proxy in front.
  • Where the secrets live: encrypted at rest, decrypted on deploy, never pasted into a chat.
  • The deploy: the same command has to work on a fresh box, after a reinstall and after a move to another provider.
  • Recovery: if the box dies tonight, can you rebuild it from the repo alone?

Every self-hosted setup answers these questions somehow, but the answers stay local decisions that never become a pattern you can reuse. rostok writes them down as files.

Spoiler: it was not.

The demo video on the tool page shows the whole run, recorded on a throwaway cloud server that the recipe rents and deletes. Here are the same steps with the commands I ran while writing this post. The deploy pictures below come from that recording.

rostok needs Deno 2.0 or newer. On Deno 2 the -g flag is required for a named install:

deno install -g -A -n rostok jsr:@rostok/cli

It downloads the CLI and its dependencies from JSR and npm, then prints "Successfully installed rostok" and the folder to add to your PATH if it is not there yet. rostok --help lists every command.

Interactively, rostok asks one question at a time and offers a default where it has one, so you press Enter through most of them. For this post I used the same wizard without prompts, passing the answers as flags. 192.0.2.10 is an address reserved for documentation, so nothing answers there:

mkdir ~/homelab && cd ~/homelab
rostok -n --var SERVER_NAME=home --var SSH_ADDRESS=root@192.0.2.10 \
  --var DOMAIN=example.com --var CONTACT_EMAIL=you@example.com \
  --stack traefik --stack omni-tools

The output, with my temporary folder shortened to ~/homelab:

Initialized:
  + ~/homelab/deno.jsonc
  + ~/homelab/.gitignore
  + ~/homelab/servers
  + ~/homelab/.env.root
rostok: couldn't probe root@192.0.2.10 over SSH: timed out after 10000ms — using the default DOCKER_GROUP_ID/PUID/PGID.
rostok: no encryption key yet — run `rostok env setup` to enable it.
Server 'home' created at ~/homelab/servers/home
rostok: no encryption key yet — run `rostok env setup` to enable it.
added traefik to home: 8 new values, 0 kept
rostok: no encryption key yet — run `rostok env setup` to enable it.
added omni-tools to home: 4 new values, 0 kept

Wrote:
  servers/home/.env
  servers/home/config.json

Next steps:
  rostok deploy home

DNS records:
  A example.com → 192.0.2.10
  A *.example.com → 192.0.2.10

rostok v1.21.0 — wizard complete.

The wizard does three things here:

  1. The wizard tries to read the server's Docker group and user ids over SSH, so the containers get the right permissions. With no server to ask, it says so and keeps the defaults.
  2. Each stack's settings go into the server's .env: 8 values for Traefik, 4 for OmniTools, every one with a default I could change later.
  3. It prints the DNS records the domain needs before a deploy can get its HTTPS certificates.

servers/home/config.json is just the list of stacks on that server:

{
  "stacks": [
    {
      "name": "traefik"
    },
    {
      "name": "omni-tools"
    }
  ]
}

Without a key, the .env files stay plaintext and gitignored. One command makes a key, and the next encrypts every .env next to itself:

rostok env setup
rostok env encrypt
rostok env status

env status then reports:

.age/key.txt:       present
.env files (2):
  .env.root
  servers/home/.env
.env.age files (2):
  .env.root.age
  servers/home/.env.age

Each line is encrypted on its own, so the names stay readable and a diff shows which setting changed. The first lines of servers/home/.env.age, cut short:

PROJECT=age64:YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6L2
SSH_ADDRESS=age64:YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOS
SSH_USER=age64:YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArM

The generated .gitignore keeps .env, .env.root and the .age/ key folder out of Git. From here on, every change the CLI makes to a .env re-encrypts it.

With DNS pointing at the server and Docker installed on it, the last step is:

rostok deploy home

It checks the server first: if Docker's group is missing, it stops with "Install Docker on the server before deploying." Then it copies the server's files and starts each stack with Docker Compose. In the recording, the deploy took about a minute:

The deploy summary: both stacks up

Then OmniTools answers on its own subdomain, with a certificate Traefik got from Let's Encrypt:

OmniTools live on HTTPS after the first deploy

The CLI is a thin shell around the catalog. Almost every stack is a folder with an ordinary compose.yml, a README.md, an optional backup.ts, and a +meta.ts that declares its settings. Here is OmniTools' whole stacks/omni-tools/+meta.ts at release 1.21.0:

// Stack metadata for `omni-tools`.
//
// A static web app of everyday tools; all processing happens in the browser, so the stack is
// stateless (no `backup.ts`). Web UI sits behind Traefik, so the stack requires it.

import type { StackMeta } from "@rostok/cli"

export default {
  name: "omni-tools",
  description: "Everyday online tools that run in your browser (iib0011/omni-tools)",
  category: "tools",
  requires: ["traefik"],
  variables: [
    {
      key: "OMNI_TOOLS_IMAGE_TAG",
      default: "latest",
      required: false,
    },
    {
      key: "OMNI_TOOLS_DOMAIN",
      question: "Public domain for OmniTools?",
      default: "tools.${DOMAIN}",
      required: true,
    },
    {
      key: "OMNI_TOOLS_CPU_LIMIT",
      question: "CPU limit for the OmniTools container?",
      default: "0.5",
      required: true,
    },
    {
      key: "OMNI_TOOLS_MEM_LIMIT",
      question: "Memory limit for the OmniTools container?",
      default: "256M",
      required: true,
    },
  ],
} satisfies StackMeta
  1. requires: ["traefik"] says the stack needs the reverse proxy in front of it.
  2. Each entry in variables becomes a line in the server's .env. Those are the 4 values the wizard added above. question is the label the wizard shows when it asks for one.
  3. default: "tools.${DOMAIN}" builds on an answer you already gave, so OmniTools gets tools.example.com without a new answer.
  4. satisfies StackMeta lets the type checker catch a mistyped field in a stack.
Typed by hand, typo by hand.

Adding a service to the catalog is one pull request with such a folder. rostok stack list shows what is there today; release 1.21.0 lists 50 stacks in 15 groups, from Traefik and Authelia to Immich, Vaultwarden, Gitea and Woodpecker CI.

Adding a stack is one PR.

rostok is not a side demo. Its README says it plainly: the repository's stacks and deploy code run my machines. I deploy four servers in different regions with it, each with a different set of services. How the rest of my production fits together is on how I run production.

Several stacks in the catalog are tools I wrote:

  • mig, the scheduler behind my booking page at meet.antonshubin.com;
  • oko, which draws dash.antonshubin.com, the status page of my servers, from Gatus;
  • zond, which lets Gatus check services that sit behind a sign-in proxy;
  • caldav-mcp and caldav-tasks-web, which give an AI assistant and a browser the events and tasks of a CalDAV server.

The configuration of my own servers stays private, because it names internal services. The catalog and the deploy code are the public part.

I used to think proper secret management meant a service such as HashiCorp Vault or Doppler. For a few servers that one person runs, that is one more thing to keep alive, and the real risk is losing the box together with the only copy of its secrets.

With age, the encrypted .env.age files sit in the same repo as everything else, and the key lives on the machine I deploy from. A fresh clone plus the key has every setting a deploy needs. rostok does the encryption inside the CLI, so there is no binary to install either. If the box dies tonight, the repo and the key should be enough to bring it back.

One binary beats one more service to babysit.
deno install -g -A -n rostok jsr:@rostok/cli
mkdir ~/homelab && cd ~/homelab
rostok

The code, the catalog and every command are in spy4x/rostok, MIT-licensed. If a service you run is missing, a pull request with its stacks/<name>/ folder is welcome.

If you run a small company and want to replace some of your SaaS bills with services on your own servers, start with a strategy session: one hour on what to move, what to keep, and what it costs to run.

Get the next post by email

Anton Shubin

Senior Full-Stack Engineer & Tech Lead

I build greenfield SaaS on a modern, lightweight stack, alone or with a team of senior developers from my own pool, and I cover full-stack, DevOps and architecture.

New posts on decisions for founders, developer craft, AI and MCP, self-hosting, and my own mistakes, about once a week at most, and only when there is one. Unsubscribe with one click.

Or follow the RSS feed

Questions, or a different result? Reply by email: hi@antonshubin.com. It comes straight to me.