How I Self-Hosted Postiz with Tailscale (and the Env Vars the Docs Don't Tell You About)
A step-by-step guide to running your own social media scheduler behind Tailscale — with the gotchas that cost me an afternoon.
I wanted a social media scheduler that I control. Not another SaaS subscription. Not another dashboard I log into that owns my data. Postiz is the best open-source option out there — 28+ platform integrations, a CLI, and a clean UI — but self-hosting it has a learning curve.
The docs will get you 80% of the way there. The other 20% — the env vars nobody mentions, the Tailscale networking trick, the Temporal stack — is what this post covers.
By the end, you'll have Postiz running on your server, accessible from anywhere via Tailscale's MagicDNS, with automatic HTTPS. No port forwarding. No Cloudflare Tunnels. No domain registrar.
What We're Building
┌──────────────────────────────────────────────┐
│ Your Server │
│ │
│ ┌──────────┐ ┌─────────────────────────┐ │
│ │ Tailscale │◄───│ network_mode: service │ │
│ │ Container │ │ (Postiz rides Tailscale's │ │
│ │ │ │ network stack) │ │
│ │ :443→5000 │ └─────────────────────────┘ │
│ └──────────┘ │ │
│ │ ▼ │
│ │ ┌─────────────────┐ │
│ │ │ Postiz App │ │
│ │ │ (port 5000) │ │
│ │ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ MagicDNS HTTPS ┌──────────────┐ │
│ postiz.tailXXXX │ PostgreSQL │ │
│ .ts.net │ Redis │ │
│ │ Temporal │ │
│ └──────────────┘ │
└──────────────────────────────────────────────┘
The key insight: Postiz doesn't expose any ports to the host. Instead, it uses network_mode: service:tailscale to piggyback on Tailscale's network stack. Tailscale handles DNS, HTTPS certificates, and the encrypted tunnel. Postiz just thinks it's serving HTTP on localhost.
Prerequisites
- A Linux server (Ubuntu 24.04 recommended) with at least 2GB RAM and 2 vCPUs
- Docker and Docker Compose installed
- A [Tailscale](undefined account (free tier is fine)
- Basic comfort with editing YAML files
Step 1: Get Your Tailscale Auth Key
This is the key that lets your container join your Tailnet without manual authentication.
- Go to [Tailscale Admin Console → Settings → Keys](undefined
- Click Generate auth key
- Set it to Reusable and Ephemeral: Off
- Add a tag like
tag:containers(you'll need this in your ACL later) - Copy the key — it starts with
tskey-auth-
Note: The free tier gives you 3 users and 100 devices. One container = one device, so you've got plenty of room.
Step 2: Clone the Postiz Docker Compose Repo
git clone undefined
cd postiz-docker-compose
This gives you the base docker-compose.yaml plus the Temporal dynamic config files. Don't run it yet — we're going to modify it significantly.
Step 3: The Tailscale Service (This Is the Secret Sauce)
Add this service to your docker-compose.yaml. It sits above Postiz in the network stack:
services:
tailscale:
image: tailscale/tailscale:latest
container_name: postiz-tailscale
restart: unless-stopped
hostname: postiz
environment:
- TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxxxxxxxxx
- TS_EXTRA_ARGS=--advertise-tags=tag:containers
- TS_SERVE_CONFIG=/config/ts.json
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- ./tailscale-state:/var/lib/tailscale
- ./tailscale-config:/config
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
- NET_RAW
healthcheck:
test: ["CMD-SHELL", "tailscale status --peers=false --json | grep -q 'Online.*true'"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
networks:
- postiz-network
- temporal-network
What's happening here:
-
TS_AUTHKEYauthenticates the container to your Tailnet -
TS_EXTRA_ARGSadvertises thetag:containersACL tag -
TS_SERVE_CONFIGpoints to a JSON file that tells Tailscale how to route traffic -
/dev/net/tunis the kernel tunnel device — required for Tailscale to create its virtual network interface -
NET_ADMINandNET_RAW** capabilities let Tailscale manipulate the network stack - The healthcheck waits for Tailscale to report
Online: truebefore Postiz starts
The Tailscale Serve Config
Create tailscale-config/ts.json:
{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"postiz.taila7d0df.ts.net:443": {
"Handlers": {
"/": {
"Proxy": "undefined"
}
}
}
}
}
This tells Tailscale:
- Listen on port 443 (HTTPS)
- When a request comes in for
postiz.tailXXXX.ts.net, proxy it toundefined(where Postiz listens)
Tailscale automatically provisions a Let's Encrypt certificate for *.ts.net domains. You get HTTPS with zero configuration.
⚠️ Replace
postiz.taila7d0df.ts.netwith your actual Tailscale MagicDNS name. You can find it in the Tailscale admin console under the machine's details, or runtailscale statusonce the container is up.
Step 4: The Postiz Service — Env Vars That Actually Matter
Here's the Postiz service, modified to work with Tailscale. This is where the docs fall short.
postiz:
image: ghcr.io/gitroomhq/postiz-app:v2.21.8
container_name: postiz
restart: always
network_mode: service:tailscale # ← THIS IS THE KEY LINE
environment:
# === URLs — MUST match your Tailscale MagicDNS name
MAIN_URL: 'undefined'
FRONTEND_URL: 'undefined'
NEXT_PUBLIC_BACKEND_URL: 'undefined'
# === Database & Redis
JWT_SECRET: 'your-random-secret-string-here'
DATABASE_URL: 'postgresql://postiz-user:postiz-password@postiz-postgres:5432/postiz-db-local'
REDIS_URL: 'redis://postiz-redis:6379'
# === Internal Communication (CRITICAL!)
BACKEND_INTERNAL_URL: 'http://localhost:3000'
TEMPORAL_ADDRESS: 'temporal:7233'
# === Feature Flags
IS_GENERAL: 'true'
DISABLE_REGISTRATION: 'false'
RUN_CRON: 'true'
NOT_SECURED: 'false'
# === Storage (Cloudflare R2)
STORAGE_PROVIDER: 'cloudflare'
CLOUDFLARE_ACCOUNT_ID: 'your-account-id'
CLOUDFLARE_ACCESS_KEY: 'your-access-key'
CLOUDFLARE_SECRET_ACCESS_KEY: 'your-secret-key'
CLOUDFLARE_BUCKETNAME: 'your-bucket-name'
CLOUDFLARE_BUCKET_URL: 'undefined'
CLOUDFLARE_REGION: 'auto'
# === Platform API Keys (fill in the platforms you use)
X_API_KEY: ''
X_API_SECRET: ''
LINKEDIN_CLIENT_ID: ''
LINKEDIN_CLIENT_SECRET: ''
# ... (add keys for platforms you want to use)
# === Misc
OPENAI_API_KEY: ''
API_LIMIT: 30
volumes:
- postiz-config:/config/
- postiz-uploads:/uploads/
depends_on:
tailscale:
condition: service_healthy
postiz-postgres:
condition: service_healthy
postiz-redis:
condition: service_healthy
The Env Vars That Cost Me an Afternoon
Here's what the official docs don't emphasize enough:
1. BACKEND_INTERNAL_URL: 'http://localhost:3000'
This is the most important env var you've never heard of. Postiz's server-side code makes API calls to itself. If this is wrong, OAuth callbacks fail silently, image uploads break, and you get mysterious 500 errors with no useful logs.
Always set this to http://localhost:3000 — not your public URL. The app listens on port 3000 internally. The network_mode: service:tailscale trick means Tailscale's port 5000 (or 443) proxies to this internal port. But the app itself needs to reach itself at localhost:3000.
2. NEXT_PUBLIC_BACKEND_URL MUST end with /api
This is the URL your browser uses to talk to the backend. If you forget the /api suffix, the frontend loads but every API call returns a 404. You'll stare at a blank dashboard wondering why nothing works.
✅ NEXT_PUBLIC_BACKEND_URL: 'undefined'
❌ NEXT_PUBLIC_BACKEND_URL: 'undefined'
The /api suffix is how the Next.js frontend routes API requests. Without it, the frontend sends requests to /graphql instead of /api/graphql, and nothing resolves.
3. MAIN_URL and FRONTEND_URL must be identical
In most web apps, these can be different. In Postiz, they interact with OAuth redirects, cookie domains, and CORS in ways that are not fully documented. Set them both to your full HTTPS URL and save yourself the debugging.
4. RUN_CRON: 'true'
Without this, your scheduled posts will never publish. Postiz uses an internal cron system (backed by Temporal) to fire posts at their scheduled time. If this is false or missing, posts sit in the queue forever.
5. NOT_SECURED: 'false'
When running behind a reverse proxy (Tailscale, in our case), this tells Postiz "trust the upstream HTTPS, don't enforce your own." If you set it to true, you'll get redirect loops and mixed content warnings.
Step 5: The Supporting Cast (PostgreSQL, Redis, Temporal)
Postiz needs Temporal for scheduling. The minimum stack looks like this:
postiz-postgres:
image: postgres:17-alpine
container_name: postiz-postgres
restart: always
environment:
POSTGRES_PASSWORD: postiz-password
POSTGRES_USER: postiz-user
POSTGRES_DB: postiz-db-local
volumes:
- postgres-volume:/var/lib/postgresql/data
networks:
- postiz-network
healthcheck:
test: pg_isready -U postiz-user -d postiz-db-local
interval: 10s
timeout: 3s
retries: 3
postiz-redis:
image: redis:7.2
container_name: postiz-redis
restart: always
healthcheck:
test: redis-cli ping
interval: 10s
timeout: 3s
retries: 3
volumes:
- postiz-redis-data:/data
networks:
- postiz-network
# Temporal stack (required for scheduled publishing)
temporal-postgresql:
container_name: temporal-postgresql
image: postgres:16
environment:
POSTGRES_PASSWORD: temporal
POSTGRES_USER: temporal
networks:
- temporal-network
volumes:
- /var/lib/postgresql/data
temporal:
container_name: temporal
ports:
- '7233:7233'
image: temporalio/auto-setup:1.28.1
depends_on:
- temporal-postgresql
environment:
- DB=postgres12
- DB_PORT=5432
- POSTGRES_USER=temporal
- POSTGRES_PWD=temporal
- POSTGRES_SEEDS=temporal-postgresql
- DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development-sql.yaml
networks:
- temporal-network
volumes:
- ./dynamicconfig:/etc/temporal/config/dynamicconfig
Why Temporal? Postiz uses Temporal for its background job engine. When you schedule a post for "tomorrow at 2 PM," a Temporal workflow sleeps until the right time, then fires the publishing pipeline. Without Temporal running, cron jobs work but scheduled publishing silently fails.
💡 Resource tip: Temporal + its PostgreSQL can consume 500MB-1GB RAM. If you're on a tight VPS, consider running Temporal on a separate machine or scaling down to 1GB RAM (set
ES_JAVA_OPTS=-Xms100m -Xmx100mif using Elasticsearch).
Step 6: Networks and Volumes
volumes:
postgres-volume:
external: false
postiz-redis-data:
external: false
postiz-config:
external: false
postiz-uploads:
external: false
networks:
postiz-network:
external: false
temporal-network:
driver: bridge
name: temporal-network
Two separate networks keep Postiz app data and Temporal data isolated. The Tailscale container bridges both networks so Postiz (which rides on Tailscale) can reach Temporal at temporal:7233.
Step 7: Bring It Up
# Make the directories Tailscale needs
mkdir -p tailscale-state tailscale-config
# Create the Tailscale serve config (edit the hostname!)
cat > tailscale-config/ts.json << 'EOF'
{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"postiz.YOUR-TAILNET.ts.net:443": {
"Handlers": {
"/": {
"Proxy": "undefined"
}
}
}
}
}
EOF
# Start everything
docker compose up -d
Wait about 60-90 seconds for everything to initialize. Tailscale needs to authenticate, provision certificates, and report healthy. Postgres and Redis need to accept connections. Temporal needs to set up its schemas.
Watch the logs to see what's happening:
# Watch all services
docker compose logs -f
# Just Postiz
docker compose logs -f postiz
# Just Tailscale (see if it authenticated)
docker compose logs -f tailscale
Verifying Everything Works
1. Check Tailscale connectivity
docker exec postiz-tailscale tailscale status
You should see the container listed as online with your MagicDNS name.
2. Check Postiz health
curl -I undefined
Should return HTTP/2 200. If it hangs, Tailscale isn't online yet — check the logs.
3. Open it in your browser
Navigate to undefined. You should see the Postiz login page. Register your admin account.
Important: After registering, immediately add your social platform integrations from the dashboard. The platform API keys you put in docker-compose.yaml enable backend communication, but you still need to connect each platform through the UI.
The Complete Gotcha List
| Gotcha | Symptom | Fix |
|---|---|---|
Missing /api in NEXT_PUBLIC_BACKEND_URL
|
Dashboard loads but no data, 404s in console | Add /api suffix |
Wrong BACKEND_INTERNAL_URL
|
OAuth callbacks fail, uploads silently break | Set to http://localhost:3000
|
RUN_CRON not set |
Scheduled posts never publish | Add RUN_CRON: 'true'
|
MAIN_URL ≠ FRONTEND_URL
|
CORS errors, cookie issues | Make them identical |
| Tailscale healthcheck too aggressive | Postiz starts before Tailscale is ready | Increase start_period to 60s |
| Port 5000 exposed directly | Bypasses Tailscale entirely | Comment out/remove ports from Postiz |
| Temporal not running | Scheduling silently fails | Ensure TEMPORAL_ADDRESS points to the right container |
Why Go Through All This?
You could use Postiz Cloud. It's great. But here's why I self-host:
Data ownership. Every API token, every scheduled post, every analytics data point lives on my server. Not someone else's.
No monthly SaaS fee. The only costs are my VPS and API usage. Postiz itself is free.
Tailscale means zero firewall configuration. I don't expose port 443 to the internet. I don't configure nginx. Tailscale's WireGuard tunnel handles all of it. My server could sit behind a NAT with no public IP and this would still work.
Platform API key security. All API keys live in environment variables on my server. They never touch Postiz's cloud. Given that these keys have write access to my social accounts, this matters.
Learning. Setting this up taught me more about Docker networking, Tailscale Serve, and Next.js deployment patterns than any tutorial could.
What's Next?
Now that Postiz is running, here's what I'm building on top of it:
- Hermes Agent integration: An AI agent that drafts posts using my voice, uploads them via the Postiz CLI, and schedules them. Zero manual posting.
- Analytics pipeline: Postiz's built-in analytics fed into a dashboard that tracks what's working across platforms.
- Multi-user setup: Adding collaborators through Postiz's team features, all behind the same Tailscale barrier.
What about you? Are you self-hosting your social media tooling, or do you trust the cloud providers? I'm genuinely curious — every self-hoster I've met has a story about the one time their config broke at 2 AM. Drop yours in the comments.
Top comments (0)