A self-hosted server that transparently caches the podcasts you listen to.
Add a feed once, get back a proxied RSS URL, add that URL to any standard podcast app. Episodes are cached to disk on first play and served locally on subsequent plays. Optional background prefetch downloads new episodes before you hit play.
go build -o podproxy .
./podproxyOpen http://localhost:8080/ui to manage feeds.
The image is published to the GitHub Container Registry and pulled automatically by the compose file:
ghcr.io/mayobytes/podproxy:latest
Copy the example config and set base_url to the address your podcast app will use to reach the server:
cp deploy/config.yaml.example deploy/config.yamlserver:
port: 8080
base_url: "http://192.168.1.100:8080"Then start the container:
cd deploy
docker compose up -dTo update: docker compose pull && docker compose up -d
Data and cache are stored in named Docker volumes (podproxy-data, podproxy-cache). To use host paths instead, replace the volume entries in docker-compose.yml:
volumes:
- /mnt/nas/podproxy/data:/app/data
- /mnt/nas/podproxy/cache:/app/cache- Go to Apps → Discover Apps → Custom App.
- Use
ghcr.io/mayobytes/podproxy:latestas the image. - Configure podproxy by adding environment variables in the Container Configuration section. ie: name:
PODPROXY_BASE_URL, value:http://192.168.1.100:8080. - In Network Configuration add the host port and container port you wish to use.
- In Storage Configuration use type "Host Path" (if using a path on your NAS) and add a host path for both
/app/data(app database) and/app/cache(cached feeds and episodes).
Use Stacks → Add stack → Web editor and paste this compose definition. Configure via environment variables instead of a config file:
services:
podproxy:
image: ghcr.io/mayobytes/podproxy:latest
ports:
- "8080:8080"
environment:
- PODPROXY_BASE_URL=http://192.168.1.100:8080
volumes:
- podproxy-data:/app/data
- podproxy-cache:/app/cache
restart: unless-stopped
volumes:
podproxy-data:
podproxy-cache:Replace 192.168.1.100 with your server's IP or hostname. See the Configuration table for additional environment variables.
To update to the latest image, go to the stack in Portainer and click Pull and redeploy.
Podproxy has no auth — all API endpoints are publicly accessible by default. If you plan to expose feeds publicly (required for some podcast apps like Apple Podcasts & Overcast), use a reverse proxy to allow read-only access to /feeds/ and /episodes/ while blocking everything else.
If you're keeping podproxy on your local network or tailnet, you can skip this.
server {
listen 443 ssl;
server_name <your domain>;
# Public read-only access to feeds and episodes
location /feeds/ {
limit_except GET HEAD {
deny all;
}
proxy_pass http://<your-server-ip>:<port>;
proxy_buffering off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /episodes/ {
limit_except GET HEAD {
deny all;
}
proxy_pass http://<your-server-ip>:<port>;
proxy_buffering off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Everything else (API, UI, health) is blocked entirely
location / {
deny all;
}
ssl_certificate /path/to/cert; # managed by Certbot
ssl_certificate_key /path/to/cert; # managed by Certbot
}
Note: Most podcast CDNs rate-limit or block VPN IPs. If your server's outbound traffic goes through a VPN and you see
unexpected EOFerrors in the logs, that's likely why.
Navigate to http://<host>:8080/ui in a browser:
- Add Feed — paste an RSS URL; the server fetches it, generates a URL-safe slug from the title, and returns a proxy URL.
- Episodes — view all episodes for a feed with their cache status.
- Refresh — force a re-fetch of the feed's RSS to discover new episodes.
- Prefetch — queue all un-cached episodes for background download.
- Cache Selected / Delete Cached — bulk mode (toggle via "Select") lets you pick individual episodes to cache or purge from disk.
- Delete — remove a feed and all its metadata.
After adding a feed via the UI or API, copy the Proxy URL (shown on the Episodes page) and add it to your podcast app instead of the original RSS URL.
POST /api/feeds { "url": "https://..." }
GET /api/feeds
DELETE /api/feeds/:id
POST /api/feeds/:id/refresh
POST /api/feeds/:id/prefetch
POST /api/feeds/:id/bulk-cache { "episode_ids": [...] }
POST /api/backups
GET /api/backups
GET /health
| Key | Env var | Default | Description |
|---|---|---|---|
server.port |
PODPROXY_PORT |
8080 |
HTTP listen port |
server.base_url |
PODPROXY_BASE_URL |
http://localhost:8080 |
Public URL (used in proxy URLs) |
storage.cache_dir |
— | ./cache |
Episode audio cache directory |
storage.data_dir |
— | ./data |
SQLite database directory |
defaults.refresh_interval_minutes |
PODPROXY_REFRESH_INTERVAL_MINUTES |
60 |
How often feeds are re-fetched |
defaults.auto_prefetch |
PODPROXY_AUTO_PREFETCH |
false |
Download new episodes automatically after each refresh |
defaults.prefetch_max_age_days |
PODPROXY_PREFETCH_MAX_AGE_DAYS |
30 |
Skip prefetch for episodes older than this (0 = no limit) |
defaults.prefetch_concurrency |
PODPROXY_PREFETCH_CONCURRENCY |
2 |
Simultaneous background download workers |
backup.dir |
— | {data_dir}/backups |
Directory for backup files |
backup.max_backups |
PODPROXY_MAX_BACKUPS |
5 |
Backups to keep; oldest pruned when exceeded (0 = unlimited) |
backup.interval_minutes |
PODPROXY_BACKUP_INTERVAL_MINUTES |
0 |
Scheduled backup interval in minutes (0 = disabled) |
Environment variables take precedence over config.yaml. This is useful for Docker deployments where you want to configure the server without mounting a config file:
docker run \
-e PODPROXY_BASE_URL=http://192.168.1.100:8080 \
-e PODPROXY_AUTO_PREFETCH=true \
-e PODPROXY_PREFETCH_CONCURRENCY=4 \
-e PODPROXY_BACKUP_INTERVAL_MINUTES=60 \
...Commit messages must follow Conventional Commits format (type: description). This is enforced in CI on pull requests.
To enable the same check locally, point Git at the checked-in hooks directory:
git config core.hooksPath .githooksRun tests before pushing:
go test ./...