Arief Rahmansyah

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.

#deployment #postiz #vps