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 appThen 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-stationThe 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 onboardIt 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.
- This Mac. Checks for the tools above and says what is missing.
- 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 runtailscale 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. - 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.
- 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.
- The media drive. Where it is mounted, and the folders the stack needs on it, which it offers to create.
- Secrets. Makes every token and password the pieces use to trust each other. None of them is ever shown.
- 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:
| File | What it holds |
|---|---|
.env | The Mac's settings. The old one is kept beside it as .env.backup-…. |
onboarding/railway-web.env | The web app's variables, to paste into Railway. |
onboarding/railway-pocketbase.env | The database's variables, to paste into Railway. |
onboarding/tailnet-policy.hujson | Your tailnet's access policy, with this Mac in it. |
onboarding/NEXT-STEPS.md | The 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.
- Create the project. At railway.com/new, choose Empty project. Copy its id from the address bar (a long UUID) into
.envasRAILWAY_PROJECT, then runmake railway-link. - Add
pocketbase. Create an Empty service namedpocketbase.- 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.
- Attach a volume mounted at
- Add
central-station. Create a service from GitHub repo, pick your copy, and name itcentral-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.
- Settings → Source → Root Directory:
- Tell the Mac the two addresses. Run
make onboardagain and give it both. It adds the web app's address toonboarding/railway-pocketbase.envasAPP_URL(where password-reset links point), so paste that file intopocketbaseonce 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=1This 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 doctorIt 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 ms401 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
- Give Transmission's controls a password. They stay open for the first start, then:
python3 scripts/onboard.py rpc-auth make restart - Install the background agents.
make agentsadds 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 - Point Plex at the drive. Add libraries for
Media/MoviesandMedia/TV Shows, andMedia/YouTubeas Other Videos with the Personal Media agent. - 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 setLIBRARIAN_APPLY=1in the installed.envand runmake deploy. - Deploy on merge.
make hooksmakes every commit or merge onmainrebuild 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.mdhas 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,startorrestart. 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 onboardand say yes to email; a Gmail app password works. It addsSMTP_*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-stationin Railway and point a CNAME at the target Railway shows. Then runmake onboardwith the new address, and pasteonboarding/railway-pocketbase.envintopocketbaseagain so reset links use it. - Photos and Plex on the web, signed in
- Two more domains on the same service, set as
PHOTOS_URLandPLEX_HOST_URL, withSITE_URLset 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/ changedTroubleshooting
Start with make doctor. These are the usual suspects:
| What you see | What 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 start | A 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 unreachable | Read 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 fails | HTTPS 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 126 | It 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 empty | PB_PUBLIC_URL is not set, or the scraper's service account cannot sign in. make doctor checks both. |
| “Forgot password?” sends nothing | Email is not set up on the pocketbase service, or APP_URL is missing there. |
| Locked out of your own account | On 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. |