Deployment guide

View on GitHub

Deploying 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) and git on that machine.
  • ffmpeg for episode clips (sudo apt install ffmpeg on Debian/Ubuntu; check with ffmpeg -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 of localhost).

  • 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 on localhost but not once it's on the public internet. To set them:

    openssl rand -hex 32   # use the output as PODIO_SESSION_SECRET
    

    Pick 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_SECRET aren't set yet when you run npm 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.local first, npm run build second. If you ever suspect this happened, rm -rf .next and rebuild.

If you forget the password

Three options, in order of how much they need:

  1. The "forgot password?" link on the login form — only appears once PODIO_OWNER_EMAIL and RESEND_API_KEY are both set in .env.local (sign up at resend.com, free tier is plenty for this). Emails a 30-minute reset link.
  2. npm run reset-password, run directly on the server over SSH — prompts for a new password and writes it straight to data/auth.json, no email involved. The everyday "I have SSH access but not my inbox handy" path.
  3. 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 whatever PODIO_PASSWORD is 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:

  1. 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.
  2. 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.
  3. Put the site key and secret key in .env.local: NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY. Same rule as PODIO_PASSWORD above — NEXT_PUBLIC_TURNSTILE_SITE_KEY has to be set before npm run build, since Next.js bakes NEXT_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):

SecretValue
ORACLE_SSH_KEYThe 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_HOSTThe machine's IP or hostname
ORACLE_USERThe 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/callback as 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 in TODO.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 plain rsync of data/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_PASSWORD stays 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/.