DEV Community

Cover image for How I Self-Hosted Postiz with Tailscale (and the Env Vars the Docs Don't Tell You About)
Aryan Iyappan
Aryan Iyappan

Posted on

How I Self-Hosted Postiz with Tailscale (and the Env Vars the Docs Don't Tell You About)

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    │            │
│                    └──────────────┘            │
└──────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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.

  1. Go to [Tailscale Admin Console → Settings → Keys](undefined
  2. Click Generate auth key
  3. Set it to Reusable and Ephemeral: Off
  4. Add a tag like tag:containers (you'll need this in your ACL later)
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What's happening here:

  • TS_AUTHKEY authenticates the container to your Tailnet
  • TS_EXTRA_ARGS advertises the tag:containers ACL tag
  • TS_SERVE_CONFIG points to a JSON file that tells Tailscale how to route traffic
  • /dev/net/tun is the kernel tunnel device — required for Tailscale to create its virtual network interface
  • NET_ADMIN and NET_RAW** capabilities let Tailscale manipulate the network stack
  • The healthcheck waits for Tailscale to report Online: true before Postiz starts

The Tailscale Serve Config

Create tailscale-config/ts.json:

{
  "TCP": {
    "443": {
      "HTTPS": true
    }
  },
  "Web": {
    "postiz.taila7d0df.ts.net:443": {
      "Handlers": {
        "/": {
          "Proxy": "undefined"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This tells Tailscale:

  1. Listen on port 443 (HTTPS)
  2. When a request comes in for postiz.tailXXXX.ts.net, proxy it to undefined (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.net with your actual Tailscale MagicDNS name. You can find it in the Tailscale admin console under the machine's details, or run tailscale status once 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
Enter fullscreen mode Exit fullscreen mode

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:3000not 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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 -Xmx100m if 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verifying Everything Works

1. Check Tailscale connectivity

docker exec postiz-tailscale tailscale status
Enter fullscreen mode Exit fullscreen mode

You should see the container listed as online with your MagicDNS name.

2. Check Postiz health

curl -I undefined
Enter fullscreen mode Exit fullscreen mode

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_URLFRONTEND_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:

  1. Data ownership. Every API token, every scheduled post, every analytics data point lives on my server. Not someone else's.

  2. No monthly SaaS fee. The only costs are my VPS and API usage. Postiz itself is free.

  3. 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.

  4. 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.

  5. 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)