ariefrahmansyah.com
Notes6 min readJanuary 15, 2026
Cover image for Deploy Postiz on VPS

Deploy Postiz on VPS

How to deploy Postiz on VPS

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.

#postiz#vps#deployment