DockerSelf-Hosting

Postiz with Docker: Self-Host a Social Scheduler (Linux, Windows, Mac)

Before you start

  • 20 min read

Boxes marked Run in terminal are what you type; plain text explains why. Check what you need first

What you get in one minute#

Postiz is an active, AGPL-3.0 social media scheduler you can self-host with Docker, and it is a good fit if you will register a developer app for each network you connect; it is not a two-click install.

Postiz official logo

Logo: Postiz official logo, from the Postiz repository At a glance:

  • One calendar for 34 social platforms, with per-network settings.
  • The official Compose stack brings Postiz, Postgres, Redis and Temporal.
  • Local uploads work out of the box; Cloudflare R2 is optional.
  • Ten platforms need no environment variables; the rest need a developer app and OAuth keys.
  • Self-hosted Postiz has no plan gate; the hosted service has no free plan.
  • The current release is v2.25.3, published 24 October 2026.
QuestionAnswer
Useful?Mostly. Capable and active, but channel setup is real work.
Current versionv2.25.3, 24 October 2026. The release notes name two security fixes and recommend an immediate upgrade.
Time neededAbout 30-60 minutes for the stack, plus provider setup. App approval can take weeks.
RAM and disk2 vCPU / 2 GB RAM / 20 GB disk floor; 4 vCPU / 8 GB RAM / 50 GB plus uploads recommended.
Ports4007 on the host for the UI; 5000 inside the container; 8080 for Temporal UI on localhost.
LicenceAGPL-3.0.
Official docsdocs.postiz.com

Postiz is for the self-hoster who wants one scheduler for several networks and control of the database and media. It is not for someone who wants the provider apps already registered. The hosted version exists for that, and it has a 7-day trial but no free plan.

What leaves your machine: your database and uploads stay on your host. The posts, media and OAuth tokens go to each social platform you connect. If you choose Cloudflare R2, media also goes to Cloudflare. Postiz Cloud is a separate hosted service.

How it fits together#

Postiz is three services in one Docker container, plus four external services.

Diagram showing your browser talking to the Postiz frontend, backend and orchestrator, with Postgres, Redis, Temporal and uploads, and the provider APIs used to publish

The frontend, backend and orchestrator share one container. Postgres, Redis, Temporal and storage are separate services. The orchestrator is the part that publishes to each network.

  • Frontend is the Next.js UI you use in the browser.
  • Backend is the NestJS API. It schedules posts, manages users and starts workflows.
  • Orchestrator runs the Temporal workflows and activities that publish, refresh tokens and send notifications.
  • Postgres stores posts, users, channels and settings.
  • Redis holds sessions and short-lived caches.
  • Temporal is the durable workflow engine, required since v2.12.0.
  • Storage is the local filesystem by default; Cloudflare R2 is supported.

The flow is simple: your browser talks to the frontend, the frontend calls the backend, the backend writes to Postgres and Redis and starts a Temporal workflow, and the orchestrator does the work. When a post is due, the orchestrator sends it to the provider API and records the result.

Before you start#

You need Docker with Linux containers. On Windows and Mac that means Docker Desktop; on Linux, Docker Engine with the Compose plugin. No GPU is needed for the scheduler. AI image and video features use external API keys if you enable them.

SystemWhat to installMinimumRecommended
LinuxDocker Engine + Compose plugin, Git2 vCPU, 2 GB RAM, 20 GB disk4 vCPU, 8 GB RAM, 50 GB + uploads
WindowsDocker Desktop with Linux containers, Git2 vCPU, 2 GB RAM, 20 GB disk4 vCPU, 8 GB RAM, 50 GB + uploads
MacDocker Desktop, Git2 vCPU, 2 GB RAM, 20 GB disk4 vCPU, 8 GB RAM, 50 GB + uploads

Check the tools before you clone anything. The same three commands work in PowerShell and Terminal.

Run in terminalbash
docker --version
docker compose version
git --version

Checklist:

  • [ ] Docker is running and can start Linux containers.
  • [ ] Ports 4007 and 8080 are free; 80 and 443 are free if you add HTTPS.
  • [ ] You have a hostname such as postiz.example.com if you want OAuth providers.
  • [ ] You can create developer apps on the networks you plan to connect.
  • [ ] Any old Compose file or config is backed up.

Tested on 2026-11-05: Linux = not tested by DIY Lab Hub; Windows = not tested; Mac = not tested. The project's own Compose test is Ubuntu 24.04 with 2 GB RAM and 2 vCPU. The project says that works for a single user with occasional posting, but leaves no headroom; plan for 4 GB or more once you add users or workflows.

Tip: If you only want to try Postiz on your own machine, you can skip the public domain and the HTTPS section. Most OAuth providers still need a URL they can redirect to, so a real multi-network install needs a hostname and TLS.

Step 1: Clone the official Compose stack#

The official Compose repo is the canonical source. It includes Postiz, Postgres, Redis and Temporal. Do not copy a Compose file from a blog post; the services and environment keys change between releases.

Linux#

Run in terminalbash
git clone https://github.com/gitroomhq/postiz-docker-compose
cd postiz-docker-compose

Windows (PowerShell)#

Run in terminalpowershell
git clone https://github.com/gitroomhq/postiz-docker-compose
Set-Location .\postiz-docker-compose

Mac (Terminal)#

Run in terminalbash
git clone https://github.com/gitroomhq/postiz-docker-compose
cd postiz-docker-compose

Check it worked. You should see the Compose file and the Temporal dynamic config folder.

Run in terminalbash
ls

On Windows:

Codepowershell
Get-ChildItem

Expected from the docs: docker-compose.yaml and dynamicconfig.

The upstream file uses the latest image. This guide pins the current release so an update is deliberate. Change the Postiz image line to:

File contentyaml
services:
  postiz:
    image: ghcr.io/gitroomhq/postiz-app:v2.25.3

The upstream file also uses restart: always. That is fine for a Compose stack. If you prefer restart: unless-stopped so a manual stop survives a reboot, change it deliberately; the rest of the guide works either way.

Tip: Keep the clone as a Git checkout. When the stack changes, git pull brings the new Compose file and the Temporal config with it.

Step 2: Set the six required values#

Postiz is configured with environment variables. The official Compose file puts them under the postiz service. You can also mount a postiz.env file at /config, or use a .env file next to the Compose file; the project does not recommend the last option. This guide edits the Compose file so everything is in one place.

The six required values are DATABASE_URL, REDIS_URL, JWT_SECRET, FRONTEND_URL, NEXT_PUBLIC_BACKEND_URL and BACKEND_INTERNAL_URL. MAIN_URL is used for some links and falls back to FRONTEND_URL.

File contentyaml
services:
  postiz:
    environment:
      MAIN_URL: "https://postiz.example.com"
      FRONTEND_URL: "https://postiz.example.com"
      NEXT_PUBLIC_BACKEND_URL: "https://postiz.example.com/api"
      JWT_SECRET: "<LONG_RANDOM_STRING>"
      DATABASE_URL: "postgresql://postiz-user:postiz-password@postiz-postgres:5432/postiz-db-local"
      REDIS_URL: "redis://postiz-redis:6379"
      BACKEND_INTERNAL_URL: "http://localhost:3000"
      TEMPORAL_ADDRESS: "temporal:7233"
      IS_GENERAL: "true"
      DISABLE_REGISTRATION: "false"
      RUN_CRON: "true"
      STORAGE_PROVIDER: "local"
      UPLOAD_DIRECTORY: "/uploads"
      NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY: "/uploads"

Three details matter more than the rest.

First, use one hostname for the frontend and the backend. FRONTEND_URL is the URL in your browser, and NEXT_PUBLIC_BACKEND_URL is the same host with /api at the end. No trailing slash. Split parent domains such as app.example.com and api.example.com put the OAuth state cookie on different domains and produce "Invalid state".

Second, JWT_SECRET must be a long random string. Set it once and keep a copy. Rotating it invalidates every session. Do not commit it to Git.

Third, the shipped Compose file uses NEXT_PUBLIC_UPLOAD_DIRECTORY, but the uploads documentation uses NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY. Use the documented key. This is exactly the kind of old key an AI assistant copies from the Compose file.

Leave DISABLE_REGISTRATION at false until your first account exists. After that, set it to true if you want a single-user instance. The docs warn that this also disables OIDC and OAuth sign-in, so do not set it before you have another way in.

Leave the Stripe keys empty. The presence of STRIPE_PUBLISHABLE_KEY switches the app into billed mode, with plans, channel caps and AI credits. A self-hosted install has no way to sell those plans. The Compose file also contains a few keys the reference now lists as removed; they are harmless clutter, but do not add billing keys.

Edit the file:

Linux#

Run in terminalbash
nano docker-compose.yaml

Windows (PowerShell)#

Run in terminalpowershell
notepad .\docker-compose.yaml

Mac (Terminal)#

Run in terminalbash
nano docker-compose.yaml

Check it worked. This prints the merged file; if it prints a YAML error, fix the indentation. Do not paste the output into a chat or an issue: it contains your JWT secret and database password.

Run in terminalbash
docker compose config

Tip: If you use a password manager, store JWT_SECRET there before you start the stack. Restoring a backup without it leaves every old session invalid.

Step 3: Start the stack#

Run Compose in the foreground and leave it open. The official docs use docker compose up; open a second terminal for checks.

Linux#

Run in terminalbash
docker compose up

Windows (PowerShell)#

Run in terminalpowershell
docker compose up

Mac (Terminal)#

Run in terminalbash
docker compose up

The first start pulls several images and waits for Postgres, Redis and Temporal to become healthy. The Postiz healthcheck has a 120-second start period, so give it a few minutes.

Open http://localhost:4007. The Temporal UI is at http://localhost:8080, bound to localhost by the Compose file. The first account becomes the organisation owner. If you did not set an email provider, Postiz activates the account immediately, so you can log in without an activation email.

Check it worked:

Run in terminalbash
docker compose ps

You should see Postiz, Postgres, Redis, Temporal and Temporal UI running. The exact columns depend on your Compose version. Postiz should become healthy; if it restarts, read the logs before changing anything.

Run in terminalbash
docker compose logs postiz --tail 100

Tip: Keep the Temporal UI on 127.0.0.1:8080. It is a debugging tool for this stack, not a second public app. The Compose file binds it to localhost; leave that binding alone.

If the UI loads but login loops, check FRONTEND_URL and NEXT_PUBLIC_BACKEND_URL first. If you changed DATABASE_URL to localhost, change it back to the service name postiz-postgres; a container does not reach the host as localhost.

Step 4: Connect a channel#

Start with a platform that needs no environment variables. Bluesky is the easiest: open Add Channel, choose Bluesky, and enter the service URL, handle and app password in the dialog. There is nothing to register and no key to put in the Compose file.

For an OAuth network, the redirect shape is always:

File contenttext
{FRONTEND_URL}/integrations/social/{provider}

So an instance at https://postiz.example.com connecting X uses https://postiz.example.com/integrations/social/x. The provider identifier is not always the display name: Google My Business uses gmb, and Farcaster uses wrapcast.

The X example: create an app in the developer portal, set permissions to Read and Write, set the app type to Native App, and add the callback URL. Then put the keys in the Compose file and restart:

File contentyaml
services:
  postiz:
    environment:
      X_API_KEY: "<YOUR_X_API_KEY>"
      X_API_SECRET: "<YOUR_X_API_SECRET>"

Restart the stack in the Postiz folder so it reads the new keys:

Linux#

Run in terminalbash
docker compose up -d

Windows (PowerShell)#

Run in terminalpowershell
docker compose up -d

Mac (Terminal)#

Run in terminalbash
docker compose up -d

LinkedIn needs the products Share on LinkedIn, Advertising API and Sign In with LinkedIn using OpenID Connect, plus the scopes the docs list. A missing scope does not always fail at LinkedIn; it can issue a token and Postiz then rejects it with NotEnoughScopes.

Check it worked: the channel appears in Channels, and a test post to the new channel publishes. If the channel appears but publishing fails, go to Troubleshooting before you delete and reconnect it.

Tip: Set the provider keys before you click Add Channel. Postiz lists every provider whether or not its keys are set; an unconfigured provider redirects with an empty or undefined client ID.

Tip: Use a fresh tab and do not refresh the provider's login page mid-flow. The OAuth state token is single-use, and a refresh can produce "Invalid state".

Put it behind HTTPS with Traefik#

This section is for a server with a domain. Local-only users can skip it. Postiz sets secure cookies and expects HTTPS in production, and most OAuth providers need a redirect URL they can reach.

With Traefik#

Create the shared network if it does not exist:

Run in terminalbash
docker network create proxy

Add the Postiz service to the external proxy network and give it labels. The upstream Traefik page uses its own postiz-network; the labels are the same idea.

File contentyaml
services:
  postiz:
    networks:
      - postiz-network
      - temporal-network
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.postiz.rule=Host(`postiz.example.com`)"
      - "traefik.http.routers.postiz.entrypoints=websecure"
      - "traefik.http.routers.postiz.tls=true"
      - "traefik.http.services.postiz.loadbalancer.server.port=5000"

networks:
  proxy:
    external: true

The upstream example uses traefik:v2.9, publishes port 443 and mounts the Docker socket read-only. It also turns on the insecure dashboard; leave that off on a public host. Traefik and Postiz must share the proxy network, and the router must point at port 5000, the internal Postiz proxy.

Without Traefik: Caddy#

Caddy is the shorter route. Put a Caddyfile in ./caddy/conf/Caddyfile and mount the folder, not the file. Mounting a single file can break Caddy's graceful reload when an editor replaces the inode.

File contentcaddy
postiz.example.com {
    reverse_proxy * postiz:5000
}

If Caddy runs on the host instead of in a container, use localhost:5000 as in the Postiz docs. If Caddy runs in the same Compose network, use the service name postiz:5000.

A minimal Caddy service:

File contentyaml
services:
  caddy:
    image: caddy:2.11.8
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./caddy/conf:/etc/caddy
      - caddy-data:/data
      - caddy-config:/config
    networks:
      - postiz-network

volumes:
  caddy-data:
  caddy-config:

Caddy gets a public certificate automatically when the domain's DNS points at the host and ports 80 and 443 are open. Check it:

Run in terminalbash
curl -I https://postiz.example.com

You want a valid TLS response, either a 200 or a redirect to the login page, not a certificate warning.

For a home-only install, use a local name and Caddy's internal CA:

File contentcaddy
postiz.example.lan {
    reverse_proxy * postiz:5000
    tls internal
}

Caddy stores its local CA under its data directory at pki/authorities/local; inside the container that is /data/caddy/pki/authorities/local. Copy the root certificate to the machine and add it to the OS trust store. Caddy may try to install it automatically, but a container often cannot. Do not use a public certificate for a .lan name.

Tip: If Caddy runs in a container, localhost means the Caddy container, not Postiz. Use postiz:5000 on the shared network.

Security and testing#

Only expose the reverse proxy ports 80 and 443. Keep 4007, 8080 and 7233 off the internet. The Compose file binds Temporal UI and Temporal gRPC to 127.0.0.1; keep those bindings.

Keep secrets in the Compose file or a postiz.env file that is not in Git. If you use a file on Linux or Mac, restrict it:

Run in terminalbash
chmod 600 postiz.env

On Windows, restrict the file to your account with the file properties. Do not put secrets in a screenshot, a chat or a public issue.

Test the stack in this order:

Run in terminalbash
docker compose ps
docker compose logs postiz --tail 100
curl -I https://postiz.example.com

Then create a draft post with an image to one network and publish it. A text-only post can succeed while media fails, so test both. If the image fails, check the uploads settings and the public URL before you change the database.

Do not set NOT_SECURED; the reference marks it as development-only. Do not set DISABLE_SSRF_PROTECTION globally unless the instance is single-tenant and all users are trusted. It disables a guard that stops the server from fetching private, loopback and link-local addresses.

Common mistakes#

We read 15 GitHub reports and one Hacker News comment. The GitHub sample is mostly complaints because it comes from an issue tracker; it is not a satisfaction rate. One Hacker News commenter called Postiz open source and said it works once the API keys are configured, but called that setup a lot of work.

ThemeReports
Connect / OAuth5
Scheduling / reliability4
Sign-in / team3
Media / uploads2
Runtime / supply chain1
MistakeWhy it happensDo this instead
Put the frontend and backend on different parent domainsThe OAuth state cookie becomes third-party and does not round-tripServe both from one hostname behind one reverse proxy; set NEXT_PUBLIC_BACKEND_URL to https://postiz.example.com/api
Leave a trailing slash in FRONTEND_URLThe redirect URI no longer matches character for characterUse https://postiz.example.com, no trailing slash; match the provider console exactly
Click a provider that has no keys setPostiz lists every provider; an unconfigured one redirects with an empty or undefined client IDSet the provider's *_CLIENT_ID and *_CLIENT_SECRET first, restart, then connect
Mount the Caddyfile as a single file at /etc/caddy/CaddyfileEditors replace the inode; Caddy's graceful reload does not see the changeMount a folder at /etc/caddy, for example ./caddy/conf:/etc/caddy
An AI assistant copies NEXT_PUBLIC_UPLOAD_DIRECTORY from the shipped Compose fileThe uploads docs use NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY; the old key can leave the frontend pointing at the wrong placeUse NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY: "/uploads" and keep UPLOAD_DIRECTORY: "/uploads"
Set STRIPE_PUBLISHABLE_KEY or leave old Stripe keys in placePresence of the key enables billing, plans and limits; a self-hosted install has no way to sell plansLeave the Stripe section empty unless you deliberately run a paid service

Tip: The issue tracker is a biased sample. Read the newest reports and the issue state before you decide a bug is still current.

Troubleshooting#

ProblemSolution
Invalid state after a provider redirectUse one hostname for frontend and backend; open a fresh tab; allow the site's cookies; do not refresh the provider page mid-flow.
Could not add providerCheck FRONTEND_URL for a trailing slash, check the provider keys, and match the redirect URI exactly.
Image posts fail with ECONNREFUSED 127.0.0.1:4007Check UPLOAD_DIRECTORY and NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY; make the upload URL reachable from the orchestrator; see issue #2196.
postgres ECONNREFUSEDUse the service name postiz-postgres, not localhost, and wait for the Postgres healthcheck.
Scheduled posts stop with no crashRestart Postiz after Temporal is healthy; the orchestrator's initial Temporal connection may not retry. See issue #2035.
NotEnoughScopes from LinkedInAdd the products and scopes from the LinkedIn provider page; a missing scope can fail after login.
Error: Blocked IPThe SSRF guard rejected a private address. Fix DNS or routing; only disable the guard on a trusted single-tenant network.

Invalid state#

The state token is stored against your session and is single-use. The most common self-hosted cause is a frontend and backend on different parent domains. Serve both from one hostname. The second most common cause is a browser that drops the cookie: try a normal window, allow the site's cookies, and start again from Add Channel.

Could not add provider#

This is the generic frontend message. Check the backend logs for the real error. A trailing slash in FRONTEND_URL, a redirect URI that does not match the provider console, or missing provider keys can all produce it.

ECONNREFUSED 127.0.0.1:4007#

This appears when the orchestrator tries to fetch an uploaded image from a URL that points at the host's own UI port inside the container. Check that UPLOAD_DIRECTORY is /uploads, that NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY is /uploads, and that the reverse proxy serves /uploads from the same host. Issue #2196 documents the exact error on a stock Compose install.

Blocked IP#

Postiz fetches some provider URLs and media server-side. It blocks private, loopback and link-local addresses to prevent SSRF. If you run a provider on your own LAN, fix DNS or routing first. DISABLE_SSRF_PROTECTION is a global switch; only use it when every user is trusted.

Known bugs#

The project's known-issues page lists current bugs with workarounds. At the time of writing these include a TikTok connect page crash, an X connect page crash, Google My Business session-replay noise, a /launches null-dereference, an AI image 400 error, and a TikTok upload that stays in queue before failing with a misleading message. Check the page before you spend hours on a fresh install.

Diagnostic commands:

Run in terminalbash
docker compose ps
docker compose logs postiz --tail 100
docker inspect postiz

docker inspect is long; look for State.Health.Status and the exit code. Read the first error in the logs, not the last; later errors are often consequences.

Tip: Do not delete the database to fix an OAuth error. The problem is almost always the URL, the cookie or the provider app.

Updates and backups#

The current release is a security release. Read the release notes, then update deliberately. If you pinned v2.25.3, change the tag to the next release; if you use latest, pull it.

Run in terminalbash
docker compose pull
docker compose up -d

If you are coming from a v1 install or a version before 2.12, read the migration guide first. Temporal is required, and the old cron/worker services are gone.

The official Compose uses named volumes for Postgres, Redis, config and uploads. A logical database dump plus a copy of config and uploads is enough for most home installs. This script uses the running containers and writes dated files:

File contentbash
#!/usr/bin/env bash
set -euo pipefail

STAMP="$(date +%Y-%m-%d)"
mkdir -p backups

docker compose exec -T postiz-postgres \
  pg_dump -U postiz-user postiz-db-local > "backups/postiz-db-$STAMP.sql"

docker compose cp postiz:/config "backups/config-$STAMP"
docker compose cp postiz:/uploads "backups/uploads-$STAMP"

This is not a volume snapshot. If you need a consistent copy while posts are publishing, stop the stack first. Store JWT_SECRET separately; a database restore without it leaves every session invalid.

On Linux or Mac, schedule it at 03:00 with cron:

File contentcron
0 3 * * * cd /path/to/postiz-docker-compose && ./backup.sh

On Windows, run the same commands in WSL or translate them to PowerShell, then create a daily task in Task Scheduler. Test a restore once: create a throwaway stack, restore the SQL dump into Postgres, copy the uploads back, and confirm an image post still shows its media.

Tip: A database dump without the uploads folder is incomplete for image posts. Back up both.

Performance and resources#

The official floor is 2 vCPU, 2 GB RAM and 20 GB disk. The project tested the Compose stack on Ubuntu 24.04 with 2 GB RAM and 2 vCPU; it works for a single user with occasional posting but leaves no headroom. For a small team, plan for 4 vCPU, 8 GB RAM and 50 GB plus uploads. DIY Lab Hub did not run a benchmark, so these are the project's numbers, not ours.

The stack includes Postgres, Redis, Temporal, Elasticsearch and the Postiz container, so RAM is shared. Watch it with:

Run in terminalbash
docker stats

Useful settings from the reference: API_LIMIT defaults to 90 but the shipped Compose sets 30; raise it if you drive Postiz from scripts. RUN_CRON can be left off on API-only instances when workers run separately. WORKER_CONCURRENCY_DIVIDER lets several workers share a platform queue without exceeding its limits. DISABLE_IMAGE_COMPRESSION keeps pixel-exact uploads at the cost of larger files.

Disk grows with uploads and with Temporal/Elasticsearch data. Monitor free space and keep the uploads volume on a disk you can expand.

FAQ#

Do I need a public domain? Not for a local-only trial. Most OAuth providers need a URL they can redirect to, so a real multi-network install needs a hostname and HTTPS.

Can one developer app cover several networks? Sometimes. Instagram (Facebook Business) uses the Facebook app keys, LinkedIn Page uses the LinkedIn keys, and Google My Business falls back to the YouTube Google credentials. Instagram Standalone uses its own Instagram keys. Other networks need their own app.

Does my data stay on my server? Your database and uploads stay on your host. Posts, media and OAuth tokens go to each social platform, and media goes to Cloudflare if you choose R2. The hosted service stores data in Postiz's infrastructure.

Can I run it on 2 GB? Yes for a single user with light use; that is the project's tested configuration. Plan for 4 GB or more with multiple users, scheduled workflows or external services on the same host.

Sources#

Checked against the official docs on 2026-11-05.