Setup guide

From a fresh clone to a running station

About an hour, most of it the first build. One command does the asking and the writing; this guide covers what it asks for, the two dashboards it sends you to, and what to check at the end.

Before you start

Central Station runs in two halves. The web app and its database run on Railway, at the same address from anywhere. Everything that touches a torrent or the media drive runs on a Mac at home. The two talk privately over your own Tailscale network, and nothing on the Mac is reachable from the internet.

Have these ready. Every one is in your own name:

  • A Mac with Apple Silicon, macOS 13 or later, that stays on, and an administrator account on it.
  • A drive for the media, connected to that Mac.
  • A Tailscale account. The free personal plan is enough.
  • A Railway account on the Hobby plan, and a GitHub account.
  • A free TMDB API key.
  • A ProtonVPN Plus plan. Port forwarding is not on free plans, and without it a torrent with few seeders stalls at 0%.
  • An IPTorrents account.
  • Optional: Plex Media Server on the Mac, a YouTube Data API key, and an email account for password resets.

Your own Tailscale account

Your station lives on your tailnet, and nothing about it is shared with another install. The onboarding has you sign the Mac in with your account and confirm it, then works out the gateway's address, the access policy and your phone's settings from it. If the Mac is signed in to someone else's tailnet, switch it before you begin.

Install the tools

With Homebrew:

xcode-select --install          # git and the compilers
brew install podman podman-compose railway pnpm
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh   # Rust, for the desktop app

Then install the standalone Tailscale app, not the App Store build, which is sandboxed. Sign in with your account, then open its menu and choose Settings → Install CLI: the stack's scripts call the tailscale command.

Rust and pnpm are only needed for Central Station.app, the desktop app. The Railway CLI is optional but saves a few steps. Python 3 ships with macOS and needs nothing extra.

Get the code

Railway deploys the web app from GitHub, so it needs a copy in your account. Make a private copy of the Central Station repository on your GitHub account (a private fork, or a new private repository you push it to), then clone it onto the Mac:

git clone git@github.com:YOUR-ACCOUNT/central-station.git
cd central-station

The clone can live anywhere, ~/Desktop included. The app and the background agents never run from it: they run from a copy in ~/Library/Application Support/MediaServer, because macOS does not let something started at login read ~/Desktop, ~/Documents or ~/Downloads.

Run the onboarding

make onboard

It goes through seven short steps. Enter skips anything you don't have yet; run it again any time, and it keeps what is set and asks only for what is missing.

  1. This Mac. Checks for the tools above and says what is missing.
  2. Tailscale. Reads tailscale status, shows the account and tailnet the Mac is signed in to, and asks you to confirm they are yours. Not signed in? It offers to run tailscale up. It then checks MagicDNS and HTTPS certificates, works out the gateway's address, writes your access policy, and asks for the OAuth client secret. Details below.
  3. Railway. The addresses of your two Railway services. Leave them blank on the first run; you create the services next, and a second run fills them in.
  4. Accounts and keys. TMDB (checked with TMDB when you enter it), the IPTorrents cookie, ProtonVPN's OpenVPN credentials, and your own account's email and password. Then the optional ones: Plex (found automatically if Plex runs on the Mac), YouTube, and email for password resets.
  5. The media drive. Where it is mounted, and the folders the stack needs on it, which it offers to create.
  6. Secrets. Makes every token and password the pieces use to trust each other. None of them is ever shown.
  7. Write it all down. Shows what it will write and asks first.

What it writes, every file readable by you alone and none of them committed:

FileWhat it holds
.envThe Mac's settings. The old one is kept beside it as .env.backup-….
onboarding/railway-web.envThe web app's variables, to paste into Railway.
onboarding/railway-pocketbase.envThe database's variables, to paste into Railway.
onboarding/tailnet-policy.hujsonYour tailnet's access policy, with this Mac in it.
onboarding/NEXT-STEPS.mdThe rest of this guide, with your own addresses filled in.

Tailscale: your own tailnet

Tailscale is the private network the web app on Railway uses to reach your Mac, and your phone uses for Plex and photo backup. The onboarding does most of it; three things live in the admin console, in this order.

1. MagicDNS and HTTPS certificates

Open DNS and turn on MagicDNS, then HTTPS Certificates. The gateway and the photo library are served over HTTPS with your tailnet's own certificate, at addresses like https://your-mac.tail1234.ts.net:9443. The onboarding checks both and waits while you switch them on.

2. The access policy

Open the policy file editor and paste in onboarding/tailnet-policy.hujson. On a new tailnet it can replace what is there; on one you already use, merge its tagOwners, hosts, acls and tests into yours. It says:

  • Your own devices keep reaching everything, this Mac included.
  • The web app's container, which joins as tag:railway, may reach the Mac's gateway on port 9443 and nothing else: not the photo library, not Plex, not SSH.
  • Its tests make the editor refuse any later change that would let the container reach more, or stop it reaching the gateway.

3. An OAuth client for Railway

Open OAuth clients (called Trust credentials in newer consoles) and generate one with the scope Auth Keys: Write and the tag tag:railway. The tag only appears once the policy above is saved. Copy the client secret, which starts tskey-client-; Tailscale shows it once.

Paste it when the onboarding asks. It checks the secret with Tailscale and puts it in onboarding/railway-web.env as TS_AUTHKEY. An OAuth client secret never expires, unlike an auth key, and every deploy uses it to join your tailnet as a fresh node that leaves when its container goes away.

Why the web app joins your tailnet

Railway gives a container no way onto a private network by itself. The web app's container runs Tailscale in userspace with --shields-up, so it can make connections into your tailnet and accept none, and a small filter lets it open exactly one: to your gateway.

Railway

One project with two services, named exactly as below: the web app finds its database at pocketbase.railway.internal, and the make railway-* commands use both names.

  1. Create the project. At railway.com/new, choose Empty project. Copy its id from the address bar (a long UUID) into .env as RAILWAY_PROJECT, then run make railway-link.
  2. Add pocketbase. Create an Empty service named pocketbase.
    • Attach a volume mounted at /pb/pb_data. Every account and the download history live there.
    • Variables → Raw Editor → paste onboarding/railway-pocketbase.env.
    • Settings → Networking → Generate Domain on port 8090.
    • Deploy it from the Mac: make railway-pocketbase.
  3. Add central-station. Create a service from GitHub repo, pick your copy, and name it central-station.
    • Settings → Source → Root Directory: /frontend.
    • Variables → Raw Editor → paste onboarding/railway-web.env.
    • Settings → Networking → Generate Domain on port 8000, or add your own domain.
  4. Tell the Mac the two addresses. Run make onboard again and give it both. It adds the web app's address to onboarding/railway-pocketbase.env as APP_URL (where password-reset links point), so paste that file into pocketbase once more.

On its first start PocketBase creates your account and the scraper's service account from those variables. Sign-ups stay closed: every other account is one you add on purpose. The database's admin panel is at your PocketBase address followed by /_/; its password is PB_ADMIN_PASSWORD in onboarding/railway-pocketbase.env.

A push to main on GitHub redeploys the web app by itself. PocketBase deploys only from the Mac, with make railway-pocketbase.

Start the Mac

make install STACK=1

This builds Central Station.app, installs it in Applications, copies the stack's runtime to ~/Library/Application Support/MediaServer, and starts the app at every login. From the menu bar it creates the Podman VM with your drive shared into it, builds and starts the containers, and serves the gateway and the photo library to your tailnet.

Without the desktop app, make up does the same once. The first start builds every image: allow twenty minutes.

The installed settings are the ones that count

Once installed, the stack runs with ~/Library/Application Support/MediaServer/.env, not the checkout's. make onboard offers to update both; after changing either by hand, run make deploy, which recreates only the services that read a changed setting.

Check everything

make doctor

It checks the whole route and says how to fix anything that fails: the settings files, the drive and its folders, that this Mac is on your tailnet with MagicDNS and HTTPS on, that the gateway and the photo library are served there and nothing is on Funnel, that the stack is running and the gateway takes its token, that both Railway services answer, that the scraper's service account can sign in, and that Railway was given this Mac's address.

Then look at the web app's deploy log on Railway. Two lines say it can reach home:

[entrypoint] joined the tailnet as hms-railway
[check] the gateway answered 401 in 212 ms

401 is right: that check sends no token. Your Tailscale machines page should list hms-railway, tagged tag:railway. Then sign in at your web app's address with the email and password you gave the onboarding.

After the first start

  1. Give Transmission's controls a password. They stay open for the first start, then:
    python3 scripts/onboard.py rpc-auth
    make restart
  2. Install the background agents. make agents adds a notification when a service goes down and a guard that pauses downloads before the disk fills. The hourly renamer that files downloads under Plex's naming goes on after you have read what it would do:
    scripts/plex-rename-sweep.sh --dry-run
    scripts/install-agents.sh plex-rename
  3. Point Plex at the drive. Add libraries for Media/Movies and Media/TV Shows, and Media/YouTube as Other Videos with the Personal Media agent.
  4. Turn on deleting after watching once you trust it. It starts in a dry run and only logs what it would do. Read make logs SERVICE=librarian, then set LIBRARIAN_APPLY=1 in the installed .env and run make deploy.
  5. Deploy on merge. make hooks makes every commit or merge on main rebuild what it changed and nothing else.

Your phone

  • Tailscale. Install the app and sign in to the same account. Turn on VPN On Demand, so Plex, photo backup and the Shortcuts always reach the Mac.
  • The web app. Open your web app's address in Safari, then Share → Add to Home Screen. It opens full screen and keeps its own sign-in for seven days at a time.
  • Photo backup. Install the Immich app. The server is your Mac's tailnet name on port 2283, for example https://your-mac.tail1234.ts.net:2283; NEXT-STEPS.md has yours. Sign in with your own account, choose Recents, and turn on background backup.
  • Status, Start and Restart. Three Shortcuts with a Run Script Over SSH action to the Mac's tailnet name, as your Mac user, each running one word: status, start or restart. Turn on Remote Login on the Mac, then install the phone's key, which can run those three words and nothing else, and only from your tailnet:
    "$HOME/Library/Application Support/MediaServer/scripts/remote-access.sh" add-key 'ssh-ed25519 AAAA… iPhone'

Optional extras

Password-reset email
Run make onboard and say yes to email; a Gmail app password works. It adds SMTP_* to the database's variables. Without them, “Forgot password?” sends nothing.
YouTube search while the Mac is off
Give the onboarding a YouTube Data API key. Search then asks Google instead of the Mac; downloads still need the Mac.
Your own domain
Add a custom domain to central-station in Railway and point a CNAME at the target Railway shows. Then run make onboard with the new address, and paste onboarding/railway-pocketbase.env into pocketbase again so reset links use it.
Photos and Plex on the web, signed in
Two more domains on the same service, set as PHOTOS_URL and PLEX_HOST_URL, with SITE_URL set to the web app's address. The photo library then opens in any browser with the site's sign-in, for the owner only.
Guests
In the database's admin panel, open users and add a record with verified on and can_download off. More on accounts.

Updating

With the deploy hook installed (make hooks), pulling new changes into your copy is the whole of an update on the Mac: git pull is a merge, so the hook rebuilds exactly the services that changed. Transmission restarts only when its own settings change, so seeding is rarely interrupted, and a change that fails to build replaces nothing. Push main to GitHub for Railway to rebuild the web app, and run make railway-pocketbase when anything under pocketbase/ changed.

git pull
git push origin main
make railway-pocketbase      # only when pocketbase/ changed

Troubleshooting

Start with make doctor. These are the usual suspects:

What you seeWhat to do
make up says “is GATEWAY_TOKEN set in .env?”The gateway refuses to start without a token of 32 characters or more. Run make onboard, then make deploy.
“States: command not found” on startA value with a space in .env has no quotes. make doctor names it; quote it, or let make onboard rewrite the file.
The site shows the Mac as unreachableRead the [entrypoint] and [check] lines in the web app's deploy log. Usually the OAuth client secret, the policy (the tag, or this Mac's address in hosts), or a GATEWAY_URL from another Mac. Then make serve.
tailscale serve failsHTTPS certificates are off for the tailnet: turn them on in the DNS settings.
Torrents sit at 0%Port forwarding needs ProtonVPN Plus. podman exec central-station_transmission_1 transmission-remote -n "user:pass" -pt should say “Port is open: Yes”.
A background agent exits with 126It points at a folder macOS will not let it read. Reinstall it with make agents, which points it at the runtime.
The status page's download history is emptyPB_PUBLIC_URL is not set, or the scraper's service account cannot sign in. make doctor checks both.
“Forgot password?” sends nothingEmail is not set up on the pocketbase service, or APP_URL is missing there.
Locked out of your own accountOn pocketbase, set APP_USER_PASSWORD to a new password and APP_USER_PASSWORD_FORCE=1, let it redeploy, sign in, then remove APP_USER_PASSWORD_FORCE.