Deployment guide
View on GitHubDeploying Pod-io
This is the self-hosted-VPS path — proven by actually deploying it this way (a small Ubuntu ARM VM, systemd, Caddy for HTTPS). For Vercel instead, see the Deploying section of the README first — the filesystem-persistence caveat there matters more than anything below.
What you need
- A machine that stays on and reachable: a VPS (Oracle Cloud's "Always Free" tier works, so do
DigitalOcean/Linode/Hetzner/etc.), not a serverless platform — Pod-io's
data/directory needs a real, persistent filesystem (see Data storage in the README). - Node.js 20.9+ (check with
node --version) andgiton that machine. ffmpegfor episode clips (sudo apt install ffmpegon Debian/Ubuntu; check withffmpeg -version). Without it the app runs fine and clips are shown as unavailable ("Clips need ffmpeg on the server").- A domain (or subdomain) you can point at the machine's IP, and a way to manage its DNS.
- SSH access to the machine.
1. Get the code onto the machine
git clone https://github.com/aamanlamba/pod-io.git
cd pod-io
npm ci
2. Point DNS at the machine
Add an A record for your domain/subdomain to the machine's public IP. If your DNS provider offers a "proxy" toggle (Cloudflare's orange cloud, for example), turn it off — leave the record DNS-only. Caddy's automatic HTTPS (below) needs Let's Encrypt's HTTP-01 challenge to reach the machine directly; a proxied record answers with the provider's own IP instead, which the challenge can't complete against.
3. Set up .env.local
cp .env.local.example .env.local
Fill in whichever of the documented variables you need (AI provider key, Patreon app credentials,
whisper.cpp paths for transcription — see the README's per-feature setup sections, and the
comments in .env.local.example itself). Three are specific to a public deployment:
-
PODIO_BASE_URL— your deployed URL (e.g.https://podio.example.com). Only needed if you have local-folder shows and are setting up the Alexa skill (see README's Alexa skill section) — RSS/Apple/Archive.org shows already carry a real public audio URL and don't need it. -
PODIO_PUBLIC_URL— e.g.https://your.domain. The origin used in episode clips' share links and Open Graph tags (so a shared clip link and its link-preview image point at your deployed domain instead oflocalhost). -
PODIO_PASSWORD/PODIO_SESSION_SECRET— gates the whole app behind a login page. Leave both blank and Pod-io runs wide open, which is fine onlocalhostbut not once it's on the public internet. To set them:openssl rand -hex 32 # use the output as PODIO_SESSION_SECRETPick your own value for
PODIO_PASSWORD(a password manager's generator is fine too). Put both in.env.local.This has to happen before the first build, not just before the server starts. Next.js decides at build time whether a page needs to check the login session per-request or can be baked into a static file — if
PODIO_PASSWORD/PODIO_SESSION_SECRETaren't set yet when you runnpm run build, it bakes the home page as static HTML containing the fully logged-in dashboard, open to anyone, no matter what you set the env vars to afterward. Always: env vars in.env.localfirst,npm run buildsecond. If you ever suspect this happened,rm -rf .nextand rebuild.
If you forget the password
Three options, in order of how much they need:
- The "forgot password?" link on the login form — only appears once
PODIO_OWNER_EMAILandRESEND_API_KEYare both set in.env.local(sign up at resend.com, free tier is plenty for this). Emails a 30-minute reset link. npm run reset-password, run directly on the server over SSH — prompts for a new password and writes it straight todata/auth.json, no email involved. The everyday "I have SSH access but not my inbox handy" path.- Delete
data/auth.json(if it exists — it's only created once you've used option 1 or 2 at least once) and restart the service. Login reverts to whateverPODIO_PASSWORDis currently set to in.env.local. The true break-glass option.
Optional: the public contact form
/contact is reachable without logging in on purpose (see proxy.ts's PUBLIC_PATHS) — that's
the whole point of a contact form — which means it's also an open spam target the moment the app
has a real public URL. It's gated behind Cloudflare Turnstile, and off (showing a plain "not set up
yet" message) until you configure it:
- It reuses the same Resend setup as password-reset above (
PODIO_OWNER_EMAIL/RESEND_API_KEY) — set those first if you haven't already. Messages land in your inbox as a plain email, no dashboard/database to check. - Add a Turnstile site at dash.cloudflare.com → Turnstile (free — and if Cloudflare's already your DNS provider per the DNS step above, there's no new account to make). Use your deployed domain as the site's hostname.
- Put the site key and secret key in
.env.local:NEXT_PUBLIC_TURNSTILE_SITE_KEYandTURNSTILE_SECRET_KEY. Same rule asPODIO_PASSWORDabove —NEXT_PUBLIC_TURNSTILE_SITE_KEYhas to be set beforenpm run build, since Next.js bakesNEXT_PUBLIC_*vars into the built client bundle at build time, not read fresh at request time.
4. Build and run
npm run build
npm run start # or: PORT=3000 npm run start
Confirm it's actually serving before wiring up anything else:
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/
Keep it running: systemd
A minimal unit file (/etc/systemd/system/pod-io.service), adjust paths/user:
[Unit]
Description=Pod-io podcast player
After=network.target
[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=/home/YOUR_USER/pod-io
ExecStart=/usr/bin/node /home/YOUR_USER/pod-io/node_modules/.bin/next start
Restart=on-failure
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now pod-io
sudo systemctl status pod-io
5. HTTPS: Caddy
Caddy gets you automatic Let's Encrypt HTTPS with essentially no configuration. Install it (see
caddyserver.com/docs/install for your OS), then
/etc/caddy/Caddyfile:
your-domain.example.com {
reverse_proxy localhost:3000
}
sudo systemctl restart caddy
That's it — Caddy requests and renews the certificate itself, and redirects HTTP to HTTPS. Check
sudo journalctl -u caddy -n 50 if it doesn't come up; the most common cause is the DNS record
from step 2 not actually resolving to the machine yet, or still being proxied.
6. Verify
curl -o /dev/null -w '%{http_code}\n' https://your-domain.example.com/
If you set PODIO_PASSWORD, this should show the "coming soon" landing page with a login form,
not your shows. Log in once to confirm the password works before telling anyone else the URL
exists.
Optional: deploy automatically on push
.github/workflows/deploy.yml (already in the repo) redeploys over SSH on every push to main
(and also on a published GitHub Release). Cutting a release is a separate, deliberate action —
see desktop-release.yml — that rebuilds and attaches the dmg/exe/apk; it doesn't gate deploys.
To use it, add three repository secrets (Settings → Secrets and variables → Actions):
| Secret | Value |
|---|---|
ORACLE_SSH_KEY | The private half of a keypair whose public half is in the deploy machine's ~/.ssh/authorized_keys (the workflow's name is a holdover from this project's own Oracle Cloud deployment — it works against any SSH host) |
ORACLE_HOST | The machine's IP or hostname |
ORACLE_USER | The SSH user (needs passwordless sudo systemctl restart podio, or adjust the workflow) |
The workflow itself still hardcodes ~/podio and systemctl restart podio (matching this
project's own still-unrenamed production VM — see the deferred domain-rename item in this file's
Deployment section) — adjust both to wherever you actually cloned this repo and whatever you
named your own systemd service, e.g. ~/pod-io and pod-io if you set it up per the systemd
section above.
The workflow itself: pulls, npm ci, rebuilds, restarts the service, then hits the deployed URL
and fails the run if it doesn't return 200. It does not touch .env.local — set that up once
by hand (step 3) and every future deploy just reuses it.
Once you're live
- Patreon OAuth, if you use it: add
https://your-domain/api/patreon/callbackas a second redirect URI in your Patreon app settings (README → Getting started). - Alexa skill: see the README's Alexa skill section — it needs the real public HTTPS URL from this point on.
- Back up
data/somewhere off the machine. Nothing in this guide does that for you, and a lost VM currently means starting over (tracked inTODO.md). - Episode transcripts, if your VPS is small: whisper.cpp needs real CPU (or Metal on Apple
Silicon) to transcribe at a usable speed — a 1-vCPU "always free" tier instance doesn't have it.
Don't install whisper.cpp on the server at all; instead generate transcripts on a machine that
can (e.g. your Mac, per README → Episode transcripts) and push them over with
SSH_KEY=~/.ssh/your_key SSH_HOST=user@your-domain npm run sync-transcripts. It's a plainrsyncofdata/transcripts/— transcript filenames are a deterministic hash of the show/episode ID, so files land in the right place and the deployed app serves them on the next request, no restart needed.
Shared sign-in with the library
The owner can sign in once on the library (podio-reader, https://library.pod-io.com) and be signed
in here too. The library sets a pod_sso cookie on .pod-io.com; Pod-io checks its signature and
asks the library over 127.0.0.1 whether the session is still current (cached for a minute), so
signing out everywhere or resetting the password on the library ends it here as well. Set the four
PODIO_SSO_* settings in .env.local (see .env.local.example) and restart.
PODIO_PASSWORDstays as a break-glass owner login (under "Other ways to sign in"), for when the library is down. Guest and friend door passwords are unchanged.- The owner's "forgot password" lives in the library now; Pod-io's own reset still works for
PODIO_PASSWORD. - podio.aamanlamba.com redirects to https://pod-io.com (a cookie can't be shared across two domains), so the deploy health check uses https://pod-io.com/.