Caption: Drafts and provider tokens on my VPS. Temporal actually fires the schedule. Nginx is the only public port.
Why I wanted this on my server
Campaign calendars and unreleased copy do not belong in a random SaaS seat. I wanted Postiz on a domain I control: connect one network, schedule a boring post, keep Postgres and uploads in backups I own.
Social APIs change. This is an app with a Temporal stack, not a static HTML form. I treat upgrades like production.
What I actually installed
Official gitroomhq/postiz-docker-compose under /opt/postiz. App, PostgreSQL, Redis, Temporal (and Temporal’s Postgres/Elasticsearch). Nginx to 127.0.0.1:4007. Floor: 2 vCPU / 4 GB RAM; more if media and many schedules. SSD. The compose file is the source of truth — I clone it, I do not invent a three-service stack from memory.
Where it broke
On a fresh Ubuntu 24.04 box this install is famous for sending you back to localhost.
The containers were up. curl -I http://127.0.0.1:4007/ looked fine. The browser on https://postiz.example.com bounced to localhost URLs because the upstream compose still had sample MAIN_URL / FRONTEND_URL / NEXT_PUBLIC_BACKEND_URL.
I set:
MAIN_URLandFRONTEND_URLtohttps://postiz.example.comNEXT_PUBLIC_BACKEND_URLtohttps://postiz.example.com/api
Then recreated containers (docker compose down without -v, then up -d). Env in an already-created Next app is not a restart away.
The other footgun: DISABLE_REGISTRATION=true before any user exists. I flipped it off from a trusted network, created admin, flipped it back.
Caption: App, Postgres, Redis, Temporal behind Nginx. First boot is slow on purpose.
Prerequisites
Docker + Compose v2, Git, Nginx, Certbot, provider developer apps only after the base stack is healthy. Hex passwords in DATABASE_URL so I do not fight URL encoding.
sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl git gnupg openssl ufw nginx certbot python3-certbot-nginx python3
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
The working install
1. Install Docker Engine
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker
docker --version
docker compose version
2. Clone the official Compose repository
sudo mkdir -p /opt/postiz
sudo chown -R "$USER":"$USER" /opt/postiz
chmod 750 /opt/postiz
git clone https://github.com/gitroomhq/postiz-docker-compose /opt/postiz
cd /opt/postiz
git status --short
git log -1 --oneline
3. Generate deployment secrets
cd /opt/postiz
export POSTIZ_DOMAIN="postiz.example.com"
export POSTIZ_DB_PASSWORD="$(openssl rand -hex 24)"
export POSTIZ_JWT_SECRET="$(openssl rand -hex 32)"
printf 'Postiz domain: %s\n' "$POSTIZ_DOMAIN"
printf 'Postiz database password: %s\n' "$POSTIZ_DB_PASSWORD"
printf 'Postiz JWT secret: %s\n' "$POSTIZ_JWT_SECRET"
JWT stays stable unless I want every session dead.
4. Harden docker-compose.yaml
cd /opt/postiz
cp docker-compose.yaml docker-compose.yaml.original
python3 - <<'PY'
from pathlib import Path
import os
import re
path = Path("docker-compose.yaml")
text = path.read_text()
domain = os.environ["POSTIZ_DOMAIN"]
db_password = os.environ["POSTIZ_DB_PASSWORD"]
jwt_secret = os.environ["POSTIZ_JWT_SECRET"]
replacements = {
r"MAIN_URL:\s*'[^']*'": f"MAIN_URL: 'https://{domain}'",
r"FRONTEND_URL:\s*'[^']*'": f"FRONTEND_URL: 'https://{domain}'",
r"NEXT_PUBLIC_BACKEND_URL:\s*'[^']*'": f"NEXT_PUBLIC_BACKEND_URL: 'https://{domain}/api'",
r"JWT_SECRET:\s*'[^']*'": f"JWT_SECRET: '{jwt_secret}'",
r"DATABASE_URL:\s*'[^']*'": f"DATABASE_URL: 'postgresql://postiz-user:{db_password}@postiz-postgres:5432/postiz-db-local'",
r"DISABLE_REGISTRATION:\s*'[^']*'": "DISABLE_REGISTRATION: 'true'",
r"POSTGRES_PASSWORD:\s*[^\n]+": f"POSTGRES_PASSWORD: {db_password}",
}
for pattern, value in replacements.items():
text = re.sub(pattern, value, text)
text = text.replace('- "4007:5000"', '- "127.0.0.1:4007:5000"')
path.write_text(text)
PY
git diff -- docker-compose.yaml
Bind is 127.0.0.1:4007:5000. DATABASE_URL password must match POSTGRES_PASSWORD.
5. Start Postiz
cd /opt/postiz
docker compose config
docker compose pull
docker compose up -d
docker compose ps
First boot can take several minutes. Postgres, Redis, Temporal Postgres, Elasticsearch, Temporal, then the app. I wait. I do not -v wipe volumes to “try again.”
docker compose logs --since=10m postiz postiz-postgres postiz-redis temporal temporal-postgresql temporal-elasticsearch
docker compose ps
curl -I http://127.0.0.1:4007/
Unhealthy Temporal often means RAM or disk, and Elasticsearch complains first.
6. Configure Nginx and HTTPS
Create /etc/nginx/sites-available/postiz.example.com:
server {
listen 80;
listen [::]:80;
server_name postiz.example.com;
client_max_body_size 100m;
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300;
proxy_pass http://127.0.0.1:4007;
}
}
sudo ln -s /etc/nginx/sites-available/postiz.example.com /etc/nginx/sites-enabled/postiz.example.com
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d postiz.example.com
sudo systemctl reload nginx
Important tips
Scheduled posts are Temporal’s job. If a draft sits forever, I check RUN_CRON, Temporal health, clock, and whether the provider token is still valid — not only the Postiz UI.
STORAGE_PROVIDER: 'local'
UPLOAD_DIRECTORY: '/uploads'
NEXT_PUBLIC_UPLOAD_DIRECTORY: '/uploads'
Local uploads are easy to tar. They also fill the disk. client_max_body_size in Nginx has to match what I upload.
Provider keys go in the same env block, one network at a time:
X_API_KEY=
X_API_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
cd /opt/postiz
docker compose down
docker compose up -d
docker compose logs --since=5m postiz
Never down -v for a config tweak.
Caption: One provider, one callback, one harmless scheduled post. Then the next provider.
First publish I actually ran
Admin login, public URL is HTTPS, small image upload, one provider, a post a few minutes out. I watch docker compose logs -f postiz temporal through that window. Then registration stays off.
Backup, expose, next step
cd /opt/postiz
mkdir -p backups
docker compose exec -T postiz-postgres pg_dump -U postiz-user postiz-db-local > "backups/postiz-db-$(date +%F).sql"
docker run --rm -v postiz_postiz-uploads:/uploads:ro -v "$PWD/backups:/backups" alpine \
tar -czf "/backups/postiz-uploads-$(date +%F).tar.gz" -C / uploads
tar -czf "backups/postiz-config-$(date +%F).tar.gz" docker-compose.yaml dynamicconfig
If the upload volume name differs, docker volume ls. Compose revision + secrets travel with the dump.
cd /opt/postiz
git fetch origin main
git diff HEAD..origin/main -- docker-compose.yaml
Caption: Compose repo revision, Postgres, Redis, uploads, Temporal data, secret list. One set.
What I have running now is Postiz on HTTPS with registration closed, localhost bind, URLs pointing at the real domain. Next I connect one low-risk account, survive one scheduled publish, then copy backups off-box.
Did you hit the same wall?
I got stuck on Postiz redirecting to localhost until MAIN_URL, FRONTEND_URL, and NEXT_PUBLIC_BACKEND_URL were the public HTTPS URLs and I recreated the containers. Did you hit the same thing, or a different one? Tell me in the comments. I read them.
Need this done on your server?
I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan