One of my goals in 2026 is to improve my marketing skills. Using a social media scheduling tool seems like a good idea to help me achieve this. There are some tools available in the market, but I prefer to use the cheapest solution. I even considered to build my own solution, but it seems too much effort for me and will waste too much LLM tokens.
Then, one day, I googled “open source social media scheduling” and this is how I met Postiz. Having more than 26K stars, it should already be a good solution, right? Using GitHub stars to decide our tech stack is always a good idea 😉
Their installation doc stated that we can deploy Postiz using Docker Compose. Although the docs say it works on 2GB RAM and 2 vCPUs, this turned out to be insufficient in my case. I’ve tried to deploy it on BizNet Gio Neo Lite SS 2.2. But with this setup, memory usage consistently exceeded 95%, eventually making the VM unresponsive. So I decided to upgrade to a more powerful server, MS 4.2 and it’s running smoothly now.
Another thing that we need to consider is how to serve our Postiz instance with our domain/subdomain. Postiz documentation lists three options: Caddy, Nginx, and Traefik. Using Traefik is the easiest way as we can simply add it to the docker-compose.yml file.
Below, you can see the docker-compose.yml file from their documentation that I modified to deploy Postiz on my VPS.
services:
postiz:
image: ghcr.io/gitroomhq/postiz-app:latest
container_name: postiz
restart: always
environment:
# === Required Settings
MAIN_URL: "https://postiz.yourdomain.com" # Change this with your domain/subdomain
FRONTEND_URL: "https://postiz.yourdomain.com" # Change this with your domain/subdomain
NEXT_PUBLIC_BACKEND_URL: "https://postiz.yourdomain.com/api" # Change this with your domain/subdomain
JWT_SECRET: 'random string that is unique to every install - just type random characters here!' # Use https://jwtsecrets.com/
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' # Update this to 'true' once you create your account. Don't forget to restart the container.
RUN_CRON: 'true'
# === Storage Settings
STORAGE_PROVIDER: 'local'
UPLOAD_DIRECTORY: '/uploads'
NEXT_PUBLIC_UPLOAD_DIRECTORY: '/uploads'
# === Cloudflare (R2) Settings
# STORAGE_PROVIDER: 'cloudflare'
# CLOUDFLARE_ACCOUNT_ID: 'your-account-id'
# CLOUDFLARE_ACCESS_KEY: 'your-access-key'
# CLOUDFLARE_SECRET_ACCESS_KEY: 'your-secret-access-key'
# CLOUDFLARE_BUCKETNAME: 'your-bucket-name'
# CLOUDFLARE_BUCKET_URL: 'https://your-bucket-url.r2.cloudflarestorage.com/'
# CLOUDFLARE_REGION: 'auto'
# === Social Media API Settings
X_API_KEY: ''
X_API_SECRET: ''
LINKEDIN_CLIENT_ID: ''
LINKEDIN_CLIENT_SECRET: ''
REDDIT_CLIENT_ID: ''
REDDIT_CLIENT_SECRET: ''
GITHUB_CLIENT_ID: ''
GITHUB_CLIENT_SECRET: ''
BEEHIIVE_API_KEY: ''
BEEHIIVE_PUBLICATION_ID: ''
THREADS_APP_ID: ''
THREADS_APP_SECRET: ''
FACEBOOK_APP_ID: ''
FACEBOOK_APP_SECRET: ''
YOUTUBE_CLIENT_ID: ''
YOUTUBE_CLIENT_SECRET: ''
TIKTOK_CLIENT_ID: ''
TIKTOK_CLIENT_SECRET: ''
PINTEREST_CLIENT_ID: ''
PINTEREST_CLIENT_SECRET: ''
DRIBBBLE_CLIENT_ID: ''
DRIBBBLE_CLIENT_SECRET: ''
DISCORD_CLIENT_ID: ''
DISCORD_CLIENT_SECRET: ''
DISCORD_BOT_TOKEN_ID: ''
SLACK_ID: ''
SLACK_SECRET: ''
SLACK_SIGNING_SECRET: ''
MASTODON_URL: 'https://mastodon.social'
MASTODON_CLIENT_ID: ''
MASTODON_CLIENT_SECRET: ''
# === OAuth & Authentik Settings
# NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME: 'Authentik'
# NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL: 'https://raw.githubusercontent.com/walkxcode/dashboard-icons/master/png/authentik.png'
# POSTIZ_GENERIC_OAUTH: 'false'
# POSTIZ_OAUTH_URL: 'https://auth.example.com'
# POSTIZ_OAUTH_AUTH_URL: 'https://auth.example.com/application/o/authorize'
# POSTIZ_OAUTH_TOKEN_URL: 'https://auth.example.com/application/o/token'
# POSTIZ_OAUTH_USERINFO_URL: 'https://authentik.example.com/application/o/userinfo'
# POSTIZ_OAUTH_CLIENT_ID: ''
# POSTIZ_OAUTH_CLIENT_SECRET: ''
# POSTIZ_OAUTH_SCOPE: "openid profile email" # Optional: uncomment to override default scope
# === Sentry
# NEXT_PUBLIC_SENTRY_DSN: 'http://spotlight:8969/stream'
# SENTRY_SPOTLIGHT: '1'
# === Misc Settings
OPENAI_API_KEY: ''
NEXT_PUBLIC_DISCORD_SUPPORT: ''
NEXT_PUBLIC_POLOTNO: ''
API_LIMIT: 30
# === Payment / Stripe Settings
FEE_AMOUNT: 0.05
STRIPE_PUBLISHABLE_KEY: ''
STRIPE_SECRET_KEY: ''
STRIPE_SIGNING_KEY: ''
STRIPE_SIGNING_KEY_CONNECT: ''
# === Developer Settings
NX_ADD_PLUGINS: false
# === Short Link Service Settings (Optional - leave blank if unused)
# DUB_TOKEN: ""
# DUB_API_ENDPOINT: "https://api.dub.co"
# DUB_SHORT_LINK_DOMAIN: "dub.sh"
# SHORT_IO_SECRET_KEY: ""
# KUTT_API_KEY: ""
# KUTT_API_ENDPOINT: "https://kutt.it/api/v2"
# KUTT_SHORT_LINK_DOMAIN: "kutt.it"
# LINK_DRIP_API_KEY: ""
# LINK_DRIP_API_ENDPOINT: "https://api.linkdrip.com/v1/"
# LINK_DRIP_SHORT_LINK_DOMAIN: "dripl.ink"
# Required Settings for Traefik
labels:
# Enable Traefik for this service
- "traefik.enable=true"
# Router for main Postiz entrypoint (on port 5000)
- "traefik.http.routers.postiz.rule=Host(`postiz.yourdomain.com`)" # Change this with your domain/subdomain
- "traefik.http.services.postiz.loadbalancer.server.port=5000" # Internal port for postiz
- "traefik.http.routers.postiz.entrypoints=websecure" # Postiz requires HTTPS
- "traefik.http.routers.postiz.tls=true"
- "traefik.http.routers.postiz.tls.certresolver=letsencrypt"
# HTTP to HTTPS redirect
- "traefik.http.routers.postiz-http.rule=Host(`postiz.yourdomain.com`)" # Change this with your domain/subdomain
- "traefik.http.routers.postiz-http.entrypoints=web"
- "traefik.http.routers.postiz-http.middlewares=postiz-redirect"
- "traefik.http.middlewares.postiz-redirect.redirectscheme.scheme=https"
- "traefik.http.middlewares.postiz-redirect.redirectscheme.permanent=true"
volumes:
- postiz-config:/config/
- postiz-uploads:/uploads/
ports:
- "4007:5000"
networks:
- postiz-network
- temporal-network
depends_on:
postiz-postgres:
condition: service_healthy
postiz-redis:
condition: service_healthy
traefik:
image: "traefik:v2.11"
container_name: "traefik"
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "[email protected]" # Change this with your email
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- traefik-letsencrypt:/letsencrypt
networks:
- postiz-network
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
# For Application Monitoring / Debugging
spotlight:
pull_policy: always
container_name: spotlight
ports:
- 8969:8969/tcp
image: ghcr.io/getsentry/spotlight:latest
networks:
- postiz-network
# -----------------------
# Temporal Stack
# -----------------------
temporal-elasticsearch:
container_name: temporal-elasticsearch
image: elasticsearch:7.17.27
environment:
- cluster.routing.allocation.disk.threshold_enabled=true
- cluster.routing.allocation.disk.watermark.low=512mb
- cluster.routing.allocation.disk.watermark.high=256mb
- cluster.routing.allocation.disk.watermark.flood_stage=128mb
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms256m -Xmx256m
- xpack.security.enabled=false
networks:
- temporal-network
expose:
- 9200
volumes:
- /var/lib/elasticsearch/data
temporal-postgresql:
container_name: temporal-postgresql
image: postgres:16
environment:
POSTGRES_PASSWORD: temporal
POSTGRES_USER: temporal
networks:
- temporal-network
expose:
- 5432
volumes:
- /var/lib/postgresql/data
temporal:
container_name: temporal
ports:
- '7233:7233'
image: temporalio/auto-setup:1.28.1
depends_on:
- temporal-postgresql
- temporal-elasticsearch
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
- ENABLE_ES=true
- ES_SEEDS=temporal-elasticsearch
- ES_VERSION=v7
- TEMPORAL_NAMESPACE=default
networks:
- temporal-network
volumes:
- ./dynamicconfig:/etc/temporal/config/dynamicconfig
labels:
kompose.volume.type: configMap
temporal-admin-tools:
container_name: temporal-admin-tools
image: temporalio/admin-tools:1.28.1-tctl-1.18.4-cli-1.4.1
environment:
- TEMPORAL_ADDRESS=temporal:7233
- TEMPORAL_CLI_ADDRESS=temporal:7233
networks:
- temporal-network
stdin_open: true
depends_on:
- temporal
tty: true
temporal-ui:
container_name: temporal-ui
image: temporalio/ui:2.34.0
environment:
- TEMPORAL_ADDRESS=temporal:7233
- TEMPORAL_CORS_ORIGINS=http://127.0.0.1:3000
networks:
- temporal-network
ports:
- '8080:8080'
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
By reading the docker-compose.yml file above, you should already realize that Postiz uses Temporal which itself requires Elasticsearch and PostgreSQL. However, Postiz is the one that consumes the most memory, up to 50%, likely due to Node.js runtime behavior and active background jobs. Followed by Elasticsearch at around 13%. The remaining containers only consumes negligible amount of memory. Running docker compose stats command on my server during normal usage gives me this output:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
42a70dd3be80 traefik 0.04% 76.74MiB / 3.824GiB 1.96% 193kB / 358kB 64.5MB / 0B 9
cadef19995ed postiz 12.35% 1.88GiB / 3.824GiB 49.16% 200MB / 355MB 316MB / 21.4MB 232
59afa5a4b201 temporal-admin-tools 0.01% 432KiB / 3.824GiB 0.01% 16.2kB / 126B 152kB / 0B 2
9714c3a95ad0 temporal 4.42% 186.1MiB / 3.824GiB 4.75% 1.19GB / 1.42GB 216MB / 0B 13
f76e795a0839 temporal-postgresql 0.27% 64.86MiB / 3.824GiB 1.66% 1.2GB / 793MB 64.7MB / 7.14GB 21
b47726579d08 postiz-postgres 0.00% 45.29MiB / 3.824GiB 1.16% 1.41MB / 2.04MB 64.5MB / 5.89MB 11
6ee187a6dfa1 postiz-redis 0.31% 9.199MiB / 3.824GiB 0.23% 214kB / 280kB 29.1MB / 16.4kB 6
f6a2db7f17a4 temporal-elasticsearch 0.38% 529.7MiB / 3.824GiB 13.53% 59.3kB / 29.8kB 113MB / 385MB 63
18d3f7aa1d35 temporal-ui 0.00% 15.72MiB / 3.824GiB 0.40% 837kB / 1.6MB 47.9MB / 0B 5
33b5980cee3f spotlight 0.00% 96.19MiB / 3.824GiB 2.46% 15.1MB / 33.5MB 105MB / 0B 11
Deploying Postiz on a VPS is straightforward. With this setup, I paid around $9 per month. While majority of existing solutions charged $29 per month, hosting my own Postiz instance is much cheaper. For my use case, the tradeoff is clear: a bit more operational complexity in exchange for full control and significantly lower cost.