How to Install Postiz on Windows: Docker Setup + Troubleshooting
Table of Contents
Postiz is the hugely popular open-source social media scheduler, a self-hosted alternative to Buffer or Hootsuite with over 33,000 GitHub stars. This guide walks through installing it on Windows with Docker Desktop.
If you are like me, and found that the official Postiz documentation is too vague, then this step by step is for you.
⚡ Read this first: the local-hosting caveat
One thing to know before you start:
Postiz only publishes posts while your machine is running.
Scheduling is handled by a background worker inside the Docker stack. If you schedule a post for 9:00 AM Tuesday and your PC is asleep, shut down, or Docker isn’t running, the post does not go out. It fires late, when the stack next starts.
Windows sleep settings are the usual culprit. A machine that suspends overnight will miss anything scheduled overnight, so check your power plan if posts land hours late.
That makes a local install great for:
- Trying Postiz before committing to a server
- Drafting and organising content
- Development and testing
…and a poor fit for reliably hitting a posting schedule. If you need posts to land on time, put it on a VPS or use the hosted version. Everything below applies to a server too - only the “keep the machine on” problem goes away.
If scheduling is a dealbreaker then you can either pay for Postiz, or host it on a VPS. A cheap VPS is enough; I’ve written up which hosts are actually worth it here.
What you need
- Windows 10 or 11
- Docker Desktop - https://docker.com/products/docker-desktop
- Git (for Git Bash) - https://git-scm.com/download/win
- ~15 GB free disk space. The stack pulls about 9.5 GB of images, plus room for volumes and overhead. (Measured on one install - image sizes drift, so treat it as a ballpark.)
- 8 GB RAM minimum; the stack idles around 3-4 GB. Note that Docker Desktop’s WSL2 backend has its own memory ceiling on top of that, so 8 GB total leaves very little headroom. 16 GB is a lot more comfortable.
Times below assume a reasonably fast connection. The image pull dominates.
Step 1 - Install and start Docker Desktop
Install it, then launch it and wait for the whale icon in the system tray to stop animating. The Docker engine runs in a Linux VM that takes 30-60 seconds to boot. Every docker command fails until it’s up.
Turn on Settings → General → Start Docker Desktop when you log in. Postiz can’t publish anything if Docker isn’t running, so on a local install this is doing real work for you.
Verify in Git Bash:
docker --version
Any version number means the CLI is reachable and the engine is up. If you get bash: docker: command not found, see Pitfall 1 below.
Step 2 - Get the compose file
Pick wherever you keep projects. In Git Bash, your Windows user folder is ~, so this puts it in C:\Users\<you>\projects\postiz:
mkdir -p ~/projects && cd ~/projects
git clone https://github.com/gitroomhq/postiz-docker-compose postiz
cd postiz
This is just the Docker Compose configuration - the application itself arrives as prebuilt images.
The folder name matters a little: Compose prefixes its volumes and networks with it. Clone into postiz and you get postiz_postgres-volume; clone into my-postiz and it’s my-postiz_postgres-volume. Commands below assume postiz.
Step 3 - Set a real JWT secret
docker-compose.yaml ships with a placeholder:
JWT_SECRET: 'random string that is unique to every install - just type random characters here!'
That string signs your login tokens, and it’s identical in every copy of the repo. Generate your own:
openssl rand -base64 32
Paste the result in place of the placeholder. Do this before first launch - changing it later invalidates existing sessions.
Two other settings worth knowing, both near the top of the file:
| Setting | Default | Meaning |
|---|---|---|
DISABLE_REGISTRATION | 'false' | Signup is open. Needed for your first account - close it afterwards (Step 6). |
MAIN_URL | http://localhost:4007 | Change only if you’re not on localhost. |
Step 4 - Start it
docker compose up -d
The first run pulls roughly 9.5 GB across 8 images - expect 5-15 minutes. Subsequent starts take well under a minute.
-d runs it detached, in the background.
Step 5 - Wait for it to actually be ready
docker compose ps
You want all services showing (healthy):
postiz Up (healthy)
postiz-postgres Up (healthy)
postiz-redis Up (healthy)
temporal Up (healthy)
temporal-postgresql Up (healthy)
temporal-elasticsearch Up (healthy)
temporal-ui Up (healthy)
temporal-admin-tools Up
Services start in dependency order - Elasticsearch, then Temporal, then Postiz - and Postiz runs database migrations on first boot. Seeing (health: starting) for two or three minutes is normal.
Then open:
Use http://, not https://. Nothing here terminates TLS, so the https URL simply fails.
Register - the first account is the admin.
Step 6 - Close registration
Once your account exists, stop anyone else from creating one. In docker-compose.yaml:
DISABLE_REGISTRATION: 'true'
Apply it:
docker compose up -d
That recreates only the changed container. Your account and data live in named volumes and are untouched - you stay logged in.
Step 7 - Keep secrets out of version control
Optional, but worth doing if you’ll ever commit this config or copy it to a server.
Docker Compose automatically reads a .env file sitting next to docker-compose.yaml. Move the secrets there:
.env
JWT_SECRET=your-generated-secret
POSTGRES_USER=postiz-user
POSTGRES_PASSWORD=postiz-password
POSTGRES_DB=postiz-db-local
docker-compose.yaml
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postiz-postgres:5432/${POSTGRES_DB}'
.gitignore
.env
The :? syntax makes Compose fail with a clear message if the variable is missing, rather than silently starting with an empty secret.
Check the substitution before restarting:
docker compose config | grep JWT_SECRET
If .env is set up correctly, this prints the resolved value. If the variable is missing, docker compose config exits with an error rather than printing anything - the grep never runs. Either outcome tells you what you need:
error while interpolating services.postiz.environment.JWT_SECRET:
required variable JWT_SECRET is missing a value: set JWT_SECRET in .env
Back .env up somewhere outside the repo. It’s git-ignored by design, which also means it isn’t backed up. Lose the JWT_SECRET and every session breaks.
Step 8 - Connect social channels
Postiz reads platform credentials from the container’s environment, not from anything in the web UI. There’s no dashboard field for these - you set them in config and restart.
Using the .env pattern from Step 7, take X as the example:
.env
X_API_KEY=your-key-here
X_API_SECRET=your-secret-here
docker-compose.yaml
X_API_KEY: ${X_API_KEY:-}
X_API_SECRET: ${X_API_SECRET:-}
The :- default keeps blank values valid, so platforms you haven’t set up stay unavailable instead of breaking startup.
Then docker compose up -d and connect the channel in the UI.
The full list of supported platforms
These are the variable names the compose file ships with. Wire up only the ones you need - the pattern is identical for each: add the variables to .env, switch the compose file to ${VAR:-}, restart.
| Platform | Variables |
|---|---|
| X (Twitter) | X_API_KEY, X_API_SECRET |
FACEBOOK_APP_ID, FACEBOOK_APP_SECRET | |
| Instagram / Threads | THREADS_APP_ID, THREADS_APP_SECRET |
LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET | |
| TikTok | TIKTOK_CLIENT_ID, TIKTOK_CLIENT_SECRET |
| YouTube | YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET |
PINTEREST_CLIENT_ID, PINTEREST_CLIENT_SECRET | |
REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET | |
| Mastodon | MASTODON_CLIENT_ID, MASTODON_CLIENT_SECRET, MASTODON_URL |
| Discord | DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN_ID |
| Slack | SLACK_ID, SLACK_SECRET, SLACK_SIGNING_SECRET |
| Dribbble | DRIBBBLE_CLIENT_ID, DRIBBBLE_CLIENT_SECRET |
| GitHub | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
| Beehiiv | BEEHIIVE_API_KEY, BEEHIIVE_PUBLICATION_ID |
MASTODON_URL defaults to https://mastodon.social - change it if you’re on a different instance. Note BEEHIIVE_* is spelled with the extra “E” in the compose file; copy it exactly.
Postiz supports more platforms than this in its UI. If one you want isn’t listed, check the configuration reference for its variable names and add them the same way.
Each platform needs an app registered on its developer portal, and each needs a callback URL whitelisted - typically http://localhost:4007/integrations/social/<platform>. Check docker compose logs postiz for the exact URL Postiz expects if a connection fails.
Start with an easy one
Platforms differ enormously in how much work they take, and it’s worth knowing before you sink an afternoon into the wrong one:
- Easy - Mastodon, Discord, Reddit, GitHub. Credentials issued immediately, no review.
- Moderate - LinkedIn (needs a company page), Pinterest, Slack.
- Hard - Facebook, Instagram/Threads, TikTok, YouTube. App review, sandbox restrictions, or business verification.
- Paid - X requires a paid API tier for write access. The free tier is read-only, so posting won’t work.
If you just want to prove the pipeline works end to end, Mastodon takes about two minutes: Preferences → Development → New application in any Mastodon instance.
These requirements change frequently - treat the grouping above as a starting point and check the current terms on each portal.
Everyday commands
docker compose ps # status
docker compose logs -f postiz # follow logs, Ctrl+C to quit
docker compose stop # stop, keeps all data
docker compose up -d # start
docker compose restart postiz # restart just the app
Data lives in Docker named volumes and survives stop, up -d, restart, and down.
docker compose down -v deletes the volumes - that erases your account, posts, and uploads. It’s the one command to be careful with.
Troubleshooting and common pitfalls
Pitfall 1: bash: docker: command not found
Docker Desktop is installed, but your shell can’t find it.
First, check Docker Desktop is actually running. No amount of PATH fixing helps if the engine is down.
If it is running, check whether the CLI is on your PATH:
echo "$PATH" | tr ':' '\n' | grep -i docker
No output means the directory is missing. Find the binary:
ls "/c/Program Files/Docker/Docker/resources/bin/docker.exe"
ls "$LOCALAPPDATA/Programs/DockerDesktop/resources/bin/docker.exe"
Recent Docker Desktop versions may install per-user to %LOCALAPPDATA%\Programs\DockerDesktop rather than C:\Program Files\Docker. Guides that assume Program Files won’t match.
Add whichever path exists to ~/.bashrc. $HOME keeps your username out of it and stays in Unix-style path format, which is what PATH wants:
echo 'export PATH="$PATH:$HOME/AppData/Local/Programs/DockerDesktop/resources/bin"' >> ~/.bashrc
source ~/.bashrc
(If yours is the Program Files install, use /c/Program Files/Docker/Docker/resources/bin instead.)
Avoid $LOCALAPPDATA here. It expands to a Windows-style path with backslashes and a drive letter (C:\Users\you\AppData\Local), and a C: in a colon-separated PATH is asking for trouble. $HOME is already /c/Users/you in Git Bash.
A subtlety worth knowing: even when the directory is correctly registered in your Windows PATH, terminals inherit their environment from explorer.exe, which may hold a stale copy from before the install. Opening a new terminal doesn’t always help - a reboot does. Editing ~/.bashrc sidesteps the whole problem.
Note that your project’s location on disk is irrelevant here. PATH is a list of absolute directories; where you run the command from doesn’t matter.
Pitfall 2: 502 Bad Gateway when logging in or registering
The page loads, but submitting the form returns nginx’s 502.
This means the frontend is fine and the backend is dead.
The postiz container isn’t one process - it runs several behind a single nginx instance:
| Process | Port | Role |
|---|---|---|
| nginx | 5000 | front door; routes everything |
| frontend (Next.js) | 4200 | the UI you see |
| backend (NestJS) | 3000 | the API - /api/* proxies here |
| workers / cron | - | publishing and scheduled jobs |
nginx serves the UI happily whether or not the backend is alive. So the page renders, then every API call - login, registration - returns 502.
Check whether the backend is listening:
docker exec postiz ss -tln | grep 3000
If ss isn’t in the image, use Node, which is always present:
docker exec postiz node -e "require('net').connect(3000,'127.0.0.1').on('connect',()=>{console.log('backend UP');process.exit(0)}).on('error',()=>{console.log('backend DOWN');process.exit(1)})"
- A
LISTENline (orbackend UP) → backend is fine; look elsewhere. - No output (or
backend DOWN) → backend is down. Find out why:
docker compose logs postiz | grep -i "backend failed" -A 5
Pitfall 3: “All services healthy” but the app is broken
This is the trap that makes Pitfall 2 confusing.
docker compose ps can report every service (healthy) while Postiz is fundamentally broken. Look at what the compose file’s health check actually tests - it fetches http://localhost:5000/, which is nginx. And nginx stays perfectly healthy serving the frontend even when the backend behind it has crashed.
So on this stack, (healthy) means “the web server is up,” not “the application works.” When something misbehaves, the port 3000 check above is a far better signal than the status column.
Pitfall 4: Don’t remove services to save RAM
The stack runs 8 containers and the Temporal portion looks like overkill for a personal install. Resist trimming it.
temporal-elasticsearch in particular looks optional and is not. Elasticsearch is genuinely optional for Temporal in general - it powers “advanced visibility,” and Temporal falls back to PostgreSQL without it. But Postiz registers custom Temporal search attributes at startup, and the PostgreSQL visibility store caps Text-type attributes at three. Postiz needs more.
Remove Elasticsearch and the backend dies during initialisation with:
Unable to create search attributes: cannot have more than 3 search attribute of type Text.
…which surfaces to you as a 502 on login, with every service reporting healthy. The compose file ships these services for a reason.
If you already removed it, restore the original Elasticsearch service, then wipe Temporal’s state so it re-initialises consistently:
docker compose down
docker volume rm postiz_temporal-postgres-data
docker compose up -d
That removes Temporal’s database only. Your Postiz account and posts live in postiz_postgres-volume and are unaffected.
Check your volume names first - Compose prefixes them with the directory name, so they’re postiz_* only if you cloned into a folder called postiz:
docker volume ls | grep temporal
Pitfall 5: /api/copilot/chat timeouts in the logs
Harmless. That’s the AI assistant feature failing because OPENAI_API_KEY is empty. Ignore it unless you want AI-generated post content, in which case supply a key.
Should you self-host locally at all?
Postiz is the best self-hosted Buffer alternative out there. Local hosting it is a genuinely good way to evaluate Postiz, learn the stack, and draft content without paying for anything. BUT, it is not a good way to hit a posting schedule.
If scheduling reliability matters, the same compose file runs on a small VPS - you’d change MAIN_URL, FRONTEND_URL, and NEXT_PUBLIC_BACKEND_URL to your domain and put a reverse proxy with TLS in front. Everything else transfers unchanged.
A cheap VPS is enough; I’ve written up which hosts are actually worth signing up for separately, along with the renewal-pricing traps to avoid.