Can I self-host Buffer?

YES, IF · ONGOING OPS— setup effort 4 of 4

YES, IF — it's called Postiz. It takes one prompt, a 4096 MB VPS, and about 300 minutes. That is $10 a month you stop paying Buffer — $120 a year on the Team plan, a metered rate, not a whole bill.

  • buffer.com
  • Marketing & content
  • prices checked 2026-08-06

Why people pay for Buffer

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Buffer holds the API relationships so you do not have to. One account connects to X, Instagram, LinkedIn, TikTok and the rest, and when one of those companies changes its rules, deprecates an endpoint or suspends an app, Buffer is the one filing the paperwork. The scheduling interface is the part you see; the part you are renting is a company that stays in good standing with a dozen platforms on your behalf.

Buffer plans and list prices
PlanList priceWhat it buys
FreefreeUp to 3 connected channels and 10 scheduled posts per channel at a time.
Essentials$5/mo metered$5 per connected channel per month, or $60 per channel per year, which the page presents as saving two months. Five channels is $25 a month.
Teamthe plan this page prices against$10/mo metered$10 per connected channel per month, or $120 per channel per year. Adds unlimited team members and draft approvals, so the seat count is not what moves this bill.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by Postiz

One project, named before the prompt, so you know what you are about to install.

One calendar for every social account, on your server, with the network API keys registered in your own name.

The only one here that is both actively developed and honest about the trade. You get the calendar, the per-network post variants, the media library and a public API, on AGPL-3.0, with no channel count and no per-channel bill. What you take on is the thing Buffer's price actually buys: every network you post to needs a developer app you register yourself, some of those registrations are reviewed by a human at Meta or X and take days, and when a platform changes its terms you are the one who reads the email. Budget a weekend for the stack and a second sitting for the paperwork.

The swap

You're paying

Buffer

$10/mo · $120/yr

is replaced by

You'd run

Postiz

ONGOING OPS · ~300 min to running · 4096 MB RAM

Buffer Team · a metered rate, not a whole bill · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
4096 MBfloor from upstream docs — not measured by us yet
Disk
20 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~300 min3+ hours, then ongoing, through the first backup

The prompt

Two paths to the same Postiz: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

358 lines · 14,826 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Postiz 2.23.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they
answer. Say why: it is the host inside every OAuth redirect URI they register at X, Meta
and LinkedIn, so moving it later means editing each of those apps by hand. Its A record
must already point at this server.

Postiz needs 4096 MB of RAM available and 20 GB free on /srv: upstream tested its compose
file on a 2 GB machine, then says to plan for 4 GB or more once PostgreSQL, Redis and the
workflow engine share a host, which is this install. A VPS sold as 4 GB has less than
4096 MB available once the OS takes its share, so this gate stops on one by design: 8 GB
is the size to buy. All four images are amd64 and arm64.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 4096 MB or free disk is under 20 GB, print both numbers and stop.
Do not install and hope. If `dig +short` prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/postiz /srv/postiz/backups /srv/postiz/config /srv/postiz/uploads
sudo install -d -m 700 /srv/postiz/postgres /srv/postiz/temporal-postgres /srv/postiz/redis
ls -la /srv/postiz
```

Assert: six directories. `backups`, `config` and `uploads` owned by the login user, and
`postgres`, `temporal-postgres` and `redis` at mode `700` owned by root. Both PostgreSQL
images and Redis chown their own data directory on first start; leave those three alone.

## 3. Secrets

Three: a password for each PostgreSQL, and the key that signs session tokens. Generate them
on the server. Do not print them, do not repeat them in your summary, and keep them out of
every log line.

```bash
umask 077
cat > /srv/postiz/.env <<EOF
POSTIZ_DOMAIN=<DOMAIN>
POSTIZ_DB_PASSWORD=$(openssl rand -hex 32)
TEMPORAL_DB_PASSWORD=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 48)
EOF
chmod 600 /srv/postiz/.env
umask 022
ls -l /srv/postiz/.env
```

Assert: mode `-rw-------`. Hex rather than base64, because two of the three travel inside
connection strings. Compose reads this file from the working directory, so every command
from here runs with /srv/postiz as that directory. Tell the user `sudo cat /srv/postiz/.env`
reads it, that rotating the signing key signs every session out, and that this file is
where the social-network keys go later.

## 4. compose.yml

Five services: Postiz, its PostgreSQL, its Redis, the Temporal server, and the PostgreSQL
Temporal keeps its workflow history in.

```bash
cat > /srv/postiz/compose.yml <<'EOF'
# Postiz · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose install ....... https://docs.postiz.com/installation/docker-compose
#   variable reference .... https://docs.postiz.com/configuration/reference
#   system requirements ... https://docs.postiz.com/installation/system-requirements
#   temporal, sql only .... https://github.com/temporalio/docker-compose/blob/main/docker-compose-postgres.yml
#
# Five services. Postiz runs its frontend, backend and orchestrator in one
# container behind an nginx on port 5000. Upstream has required Temporal since
# v2.12.0, and Temporal keeps workflow history in its own database, so the two
# PostgreSQL services differ: the first holds your posts, the second holds
# state you can throw away. Upstream also ships Elasticsearch, a Temporal web
# UI and admin-tools; Temporal's own PostgreSQL-only compose has none of them,
# so neither does this. Digests read 2026-08-06; all four are amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: postiz

services:
  postiz-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: postiz
      POSTGRES_USER: postiz
      POSTGRES_PASSWORD: ${POSTIZ_DB_PASSWORD}
    volumes:
      - /srv/postiz/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postiz -d postiz"]
      interval: 10s
      retries: 12
    # No `ports:`: 5432 is reachable only from the other containers.

  postiz-redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/postiz/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  temporal-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_USER: temporal
      POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD}
    volumes:
      - /srv/postiz/temporal-postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U temporal"]
      interval: 10s
      retries: 12

  temporal:
    image: temporalio/auto-setup:1.28.1@sha256:607d68caa111338d754771efb876c92dfcdae06d056e4530bb31cd0f37406e6a
    restart: unless-stopped
    environment:
      # postgres12 names the driver, not a version floor.
      DB: postgres12
      DB_PORT: "5432"
      POSTGRES_USER: temporal
      POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD}
      POSTGRES_SEEDS: temporal-postgres
    # No dynamic-config mount: the image ships its own, and Postiz overrides
    # nothing in it.
    healthcheck:
      test: ["CMD", "temporal", "operator", "cluster", "health", "--address", "temporal:7233"]
      interval: 10s
      retries: 30
    depends_on:
      temporal-postgres:
        condition: service_healthy

  postiz:
    image: ghcr.io/gitroomhq/postiz-app:v2.23.0@sha256:785f97312f66a347fb96cdccc4ded5a33ced69a672c89a9adc8054e7d6a21dc5
    restart: unless-stopped
    environment:
      # /api because the container's nginx routes /api/ to the backend.
      FRONTEND_URL: "https://${POSTIZ_DOMAIN}"
      NEXT_PUBLIC_BACKEND_URL: "https://${POSTIZ_DOMAIN}/api"
      BACKEND_INTERNAL_URL: "http://localhost:3000"
      DATABASE_URL: "postgresql://postiz:${POSTIZ_DB_PASSWORD}@postiz-postgres:5432/postiz"
      REDIS_URL: "redis://postiz-redis:6379"
      JWT_SECRET: ${JWT_SECRET}
      TEMPORAL_ADDRESS: "temporal:7233"
      # RUN_CRON registers the workflows that post on a schedule.
      IS_GENERAL: "true"
      RUN_CRON: "true"
      # One signup while the database is empty, then the page shuts.
      DISABLE_REGISTRATION: "true"
      STORAGE_PROVIDER: "local"
      UPLOAD_DIRECTORY: "/uploads"
      NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY: "/uploads"
    volumes:
      - /srv/postiz/config:/config
      - /srv/postiz/uploads:/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8111.
      - "127.0.0.1:8111:5000"
    depends_on:
      postiz-postgres:
        condition: service_healthy
      postiz-redis:
        condition: service_healthy
      temporal:
        condition: service_healthy
EOF
cd /srv/postiz && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK` and nothing else. A warning about an unset variable means step 3 did
not write .env.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first:
a syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-postiz
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Postiz · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://docs.postiz.com/reverse-proxies/caddy
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. It is also
# POSTIZ_DOMAIN in .env, and the host every OAuth redirect URI points back at.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting a channel bounces out to a provider.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8111 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall. One upstream serves both halves
	# of the app: the nginx inside the container sends /api/ to the backend and
	# everything else to the frontend.
	reverse_proxy 127.0.0.1:8111
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-postiz, reload, and report what it objected to. Caddy issues
the certificate on the first request and renews it.

## 6. Firewall

Two ports open, both Caddy's, idempotent:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the way in, 443/udp is
HTTP/3. 8111 is bound to 127.0.0.1, and compose publishes no host port at all for the
databases, the cache or the workflow engine. Assert: `ufw status verbose` prints
`Status: active`, shows those three rules, and nothing for 8111, 5432, 6379 or 7233.

## 7. Start and verify

The first start is slow: Temporal builds two schemas, then Postiz runs its migrations.

```bash
cd /srv/postiz
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
docker compose exec -T temporal temporal operator cluster health --address temporal:7233
curl -sS https://<DOMAIN>/api/
curl -sS https://<DOMAIN>/api/auth/can-register
```

Assert all four, printing what you received for each. The loop ends on `200`. The Temporal
check prints `SERVING`. The third prints `App is running!`, the backend answering through
Caddy. The fourth prints `{"register":true}`, the sign-up window open because the database
holds no account yet. If any of the four misses, stop, run
`docker compose logs --tail 40 postiz` and `docker compose logs --tail 20 temporal`, and
name the likely cause: a Temporal container stuck below healthy points at step 3, where an
empty password leaves its PostgreSQL refusing connections. A running container is not
success.

The first screen is https://<DOMAIN>/auth: the heading `Sign Up` over an email, password
and company form, with a `Create Account` button.

STOP: tell the user to open https://<DOMAIN>/auth, create the one account this install will
have, and wait. Do not continue until they confirm. Then check the window shut:

```bash
curl -sS https://<DOMAIN>/api/auth/can-register
```

Assert: `{"register":false}`. Upstream documents `DISABLE_REGISTRATION`, which compose.yml
sets, as allowing one signup and then closing the sign-up page, so this proves the account
it allowed is the user's own. Have them reload https://<DOMAIN>/auth and confirm it reads
`Registration is disabled`. Both asserts pass before you claim success.

## 8. First backup and restore

Two artifacts: the dump holds accounts, drafts and the schedule, the archive holds the
files that rebuild the service around it, uploaded media included. Temporal's database is in
neither, because auto-setup rebuilds it from empty.

```bash
cd /srv/postiz
docker compose exec -T postiz-postgres pg_dump -U postiz -d postiz | gzip > /srv/postiz/backups/postiz-db-$(date +%F).sql.gz
sudo tar -czf /srv/postiz/backups/postiz-config-$(date +%F).tar.gz -C /srv/postiz compose.yml .env config uploads -C /etc/caddy Caddyfile
ls -lh /srv/postiz/backups/
```

Assert: both exist and are non-empty. Print both sizes. Nothing stops: `pg_dump` snapshots
a running database consistently. A backup on the same disk is not a backup, so run this
from the user's machine:

```bash
mkdir -p ~/backups/postiz
scp vps:/srv/postiz/backups/* ~/backups/postiz/
```

To restore: `docker compose down`, remove /srv/postiz/postgres and
/srv/postiz/temporal-postgres, recreate both as in step 2, untar the archive into
/srv/postiz, `docker compose up -d postiz-postgres`, wait for healthy, pipe `gunzip -c` on
the `.sql.gz` into `docker compose exec -T postiz-postgres psql -U postiz -d postiz`, then
`docker compose up -d`. Drafts and calendar come back; a channel comes back only if its
token has not expired meanwhile.

## 9. Updating later

New versions are listed at https://github.com/gitroomhq/postiz-app/releases. Take both
backups first, then edit the image line in /srv/postiz/compose.yml to the new tag and
digest:

```bash
cd /srv/postiz
docker compose pull
docker compose up -d
docker compose logs --tail 30 postiz
```

Watch that log until it settles, then re-run the four checks from step 7. Which services
the stack needs changes between releases, so read the notes, not only the tag.

## 10. What will probably go wrong

The first four minutes look like a broken install. I watched https://<DOMAIN>/api/ return
`502` over and over while `docker compose ps` showed every container up, and went hunting a
Caddy mistake that was not there. Temporal was still building its schemas, and Postiz
answers nothing until it can reach Temporal. Watch `docker compose logs -f temporal`, then
the Postiz log, and give step 7 its ten minutes first.

## 11. Out of scope

- Do not connect a social network, and do not create developer accounts or apps for the
  user. Each network needs an app registered in that company's own developer portal, a
  redirect URI under `https://<DOMAIN>/integrations/social/`, and its client id and secret
  added to /srv/postiz/.env. Several are reviewed by a person at the other company
  and take days, which you cannot clear and a STOP cannot wait out. Say that in your
  summary, with https://docs.postiz.com/providers/overview.
- Do not configure SMTP or set `EMAIL_PROVIDER`. With no mail provider set, upstream
  activates accounts without email, which this install relies on.
- Do not install the Temporal web UI, the admin-tools container or Elasticsearch. Upstream
  ships all three; each is a service to watch.
- Do not set `OPENAI_API_KEY`, the Stripe keys, or `STORAGE_PROVIDER=cloudflare`.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Postiz 2.23.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over
`ssh vps` unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A
record already points at the box.

Read this before step 1. `<DOMAIN>` is the host inside every OAuth redirect URI you will
register at X, Meta, LinkedIn and every other network you connect. Each of those apps is
registered by hand in that company's developer portal, several of them are reviewed by a
person and take days, and changing your hostname afterwards means editing every one of them
again. Pick the hostname you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `4096` MB available, at least `20` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a
minute, and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a
hostname that does not resolve. On memory, believe the number rather than the plan you
bought: this stack is five containers, upstream tested its own compose file on a 2 GB
machine and then said to plan for 4 GB or more once PostgreSQL, Redis and the workflow
engine share a host, which is exactly what you are about to do. A 2 GB box will start and
then die during the first migration, and a box sold as 4 GB usually shows less than
4096 MB available once the OS takes its share, so it fails this gate too: 8 GB is the size
that clears it.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/postiz /srv/postiz/backups /srv/postiz/config /srv/postiz/uploads
sudo install -d -m 700 /srv/postiz/postgres /srv/postiz/temporal-postgres /srv/postiz/redis
ls -la /srv/postiz
```

You should see: six directories. `backups`, `config` and `uploads` owned by you, and
`postgres`, `temporal-postgres` and `redis` at mode `drwx------` owned by root.

If you do not: leave the last three owned by root on purpose. Both PostgreSQL images and
the Redis image chown their own data directory the first time they start, and one you have
already chowned to yourself makes PostgreSQL refuse to initialise.

## 3. Secrets

Three secrets: a password for each of the two PostgreSQL services, and the key that signs
your session tokens. All three are generated here, on the server, straight into a file only
you can read.

```bash
umask 077
cat > /srv/postiz/.env <<EOF
POSTIZ_DOMAIN=<DOMAIN>
POSTIZ_DB_PASSWORD=$(openssl rand -hex 32)
TEMPORAL_DB_PASSWORD=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 48)
EOF
chmod 600 /srv/postiz/.env
umask 022
ls -l /srv/postiz/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>`
on the first line with your real hostname before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens
if you pasted the lines separately in different shells. Run `chmod 600 /srv/postiz/.env` and
carry on. If the file already existed from an earlier attempt, this block has now
overwritten all three values, which is fine before the databases exist and a problem
afterwards: a PostgreSQL volume keeps the password it was created with, so a changed
password against an existing volume shows up as an authentication failure in the Postiz log
rather than as anything about passwords.

Do not paste that file, any of those three values, or any command output containing them
into this chat window. This is the one rule the agent path never has to think about and
this path does: the values are on your server, and a chat window is somebody else's
computer.

Docker Compose reads this file from the working directory, so run every command from here
on with /srv/postiz as your working directory. It is also the file the social-network keys
go into later.

## 4. compose.yml

Paste the whole block at once, including the last two lines. Five services: Postiz, its
PostgreSQL, its Redis, the Temporal server, and the PostgreSQL Temporal keeps its workflow
history in.

```bash
cat > /srv/postiz/compose.yml <<'EOF'
# Postiz · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose install ....... https://docs.postiz.com/installation/docker-compose
#   variable reference .... https://docs.postiz.com/configuration/reference
#   system requirements ... https://docs.postiz.com/installation/system-requirements
#   temporal, sql only .... https://github.com/temporalio/docker-compose/blob/main/docker-compose-postgres.yml
#
# Five services. Postiz runs its frontend, backend and orchestrator in one
# container behind an nginx on port 5000. Upstream has required Temporal since
# v2.12.0, and Temporal keeps workflow history in its own database, so the two
# PostgreSQL services differ: the first holds your posts, the second holds
# state you can throw away. Upstream also ships Elasticsearch, a Temporal web
# UI and admin-tools; Temporal's own PostgreSQL-only compose has none of them,
# so neither does this. Digests read 2026-08-06; all four are amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: postiz

services:
  postiz-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: postiz
      POSTGRES_USER: postiz
      POSTGRES_PASSWORD: ${POSTIZ_DB_PASSWORD}
    volumes:
      - /srv/postiz/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postiz -d postiz"]
      interval: 10s
      retries: 12
    # No `ports:`: 5432 is reachable only from the other containers.

  postiz-redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/postiz/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  temporal-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_USER: temporal
      POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD}
    volumes:
      - /srv/postiz/temporal-postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U temporal"]
      interval: 10s
      retries: 12

  temporal:
    image: temporalio/auto-setup:1.28.1@sha256:607d68caa111338d754771efb876c92dfcdae06d056e4530bb31cd0f37406e6a
    restart: unless-stopped
    environment:
      # postgres12 names the driver, not a version floor.
      DB: postgres12
      DB_PORT: "5432"
      POSTGRES_USER: temporal
      POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD}
      POSTGRES_SEEDS: temporal-postgres
    # No dynamic-config mount: the image ships its own, and Postiz overrides
    # nothing in it.
    healthcheck:
      test: ["CMD", "temporal", "operator", "cluster", "health", "--address", "temporal:7233"]
      interval: 10s
      retries: 30
    depends_on:
      temporal-postgres:
        condition: service_healthy

  postiz:
    image: ghcr.io/gitroomhq/postiz-app:v2.23.0@sha256:785f97312f66a347fb96cdccc4ded5a33ced69a672c89a9adc8054e7d6a21dc5
    restart: unless-stopped
    environment:
      # /api because the container's nginx routes /api/ to the backend.
      FRONTEND_URL: "https://${POSTIZ_DOMAIN}"
      NEXT_PUBLIC_BACKEND_URL: "https://${POSTIZ_DOMAIN}/api"
      BACKEND_INTERNAL_URL: "http://localhost:3000"
      DATABASE_URL: "postgresql://postiz:${POSTIZ_DB_PASSWORD}@postiz-postgres:5432/postiz"
      REDIS_URL: "redis://postiz-redis:6379"
      JWT_SECRET: ${JWT_SECRET}
      TEMPORAL_ADDRESS: "temporal:7233"
      # RUN_CRON registers the workflows that post on a schedule.
      IS_GENERAL: "true"
      RUN_CRON: "true"
      # One signup while the database is empty, then the page shuts.
      DISABLE_REGISTRATION: "true"
      STORAGE_PROVIDER: "local"
      UPLOAD_DIRECTORY: "/uploads"
      NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY: "/uploads"
    volumes:
      - /srv/postiz/config:/config
      - /srv/postiz/uploads:/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8111.
      - "127.0.0.1:8111:5000"
    depends_on:
      postiz-postgres:
        condition: service_healthy
      postiz-redis:
        condition: service_healthy
      temporal:
        condition: service_healthy
EOF
cd /srv/postiz && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `variable is not set` means step 3 did not write .env into /srv/postiz, or
you are in a different directory. `services must be a mapping` means the indentation was
lost between the page and your terminal: run `rm /srv/postiz/compose.yml` and paste again
in one go. Upstream's own compose file also runs Elasticsearch, a Temporal web interface
and an interactive admin-tools container. This one runs none of the three, because
Temporal publishes a PostgreSQL-only compose file without them and three fewer containers
on a 4 GB box is the difference between comfortable and not.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>`
in the block with your hostname before you paste. The first line takes a copy, because a
syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-postiz
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Postiz · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://docs.postiz.com/reverse-proxies/caddy
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. It is also
# POSTIZ_DOMAIN in .env, and the host every OAuth redirect URI points back at.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting a channel bounces out to a provider.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8111 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall. One upstream serves both halves
	# of the app: the nginx inside the container sends /api/ to the backend and
	# everything else to the frontend.
	reverse_proxy 127.0.0.1:8111
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-postiz /etc/caddy/Caddyfile`,
reload, and paste again. There is one upstream and no second route, because the nginx
inside the Postiz container already sends `/api/` to the backend and everything else to the
frontend. Caddy issues the certificate on the first request and renews it on its own.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8111`, `5432`, `6379` or `7233`.

If you do not: delete anything for those four with, for example,
`sudo ufw delete allow 8111`. 8111 is bound to 127.0.0.1 by the compose file, and the two
databases, the cache and the workflow engine publish no host port at all, so there is
nothing for a firewall rule to apply to. `Status: inactive` is a different problem: Prompt
Zero left this firewall enabled, so something has turned it off since, and `sudo ufw enable`
puts it back before you go any further.

## 7. Start and verify

The first start is the slow one. Temporal builds two database schemas before it reports
healthy, and Postiz then runs its own migrations, so the loop below is allowed ten minutes.

```bash
cd /srv/postiz
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
docker compose exec -T temporal temporal operator cluster health --address temporal:7233
curl -sS https://<DOMAIN>/api/
curl -sS https://<DOMAIN>/api/auth/can-register
```

You should see, in order: the loop climbing through `502` and ending on `200`, then
`SERVING`, then `App is running!`, then `{"register":true}`.

If you do not: a loop that never leaves `502` after ten minutes is usually Postiz waiting on
Temporal. Run `docker compose logs --tail 20 temporal` first, because a Temporal container
that never reports healthy means step 3's password did not reach its PostgreSQL, then
`docker compose logs --tail 40 postiz`. A `404` where you expected `App is running!` means
Caddy is reaching something other than the container: check `docker compose ps`. The
`{"register":true}` is the one worth understanding, because it says the sign-up window is
open and the database has no account in it yet, which is exactly the state the next step
depends on.

Now open https://<DOMAIN>/auth in a browser. The first screen shows the heading `Sign Up`
over an email, password and company form, with a `Create Account` button. Create the one
account this install will have, then come back and close the window behind you:

```bash
curl -sS https://<DOMAIN>/api/auth/can-register
```

You should see: `{"register":false}`.

If you do not: `{"register":true}` after you registered means the account was not created,
so try again in the browser and watch for an error under the form. `DISABLE_REGISTRATION`
is already true in compose.yml, and upstream documents that as allowing a single signup and
then disabling the sign-up page, so a `false` here is the proof that the one account it
allowed is yours. Reload https://<DOMAIN>/auth and confirm it now reads
`Registration is disabled`. A green `docker compose ps` is not success; these two checks
are.

## 8. First backup and restore

Two artifacts. The database dump holds the accounts, the drafts and the schedule. The
config archive holds the files that rebuild the service around it, uploaded media included.
Temporal's own database is in neither, because the auto-setup image rebuilds it from empty.

```bash
cd /srv/postiz
docker compose exec -T postiz-postgres pg_dump -U postiz -d postiz | gzip > /srv/postiz/backups/postiz-db-$(date +%F).sql.gz
sudo tar -czf /srv/postiz/backups/postiz-config-$(date +%F).tar.gz -C /srv/postiz compose.yml .env config uploads -C /etc/caddy Caddyfile
ls -lh /srv/postiz/backups/
```

You should see: two files, both non-empty. Nothing goes offline, because `pg_dump` snapshots
a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed
and the shell created the file anyway. Run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/postiz
scp vps:/srv/postiz/backups/* ~/backups/postiz/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/postiz/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives.

Now prove the restore, today, while the only thing at risk is one empty account:

```bash
cd /srv/postiz
docker compose down
sudo rm -rf /srv/postiz/postgres /srv/postiz/temporal-postgres
sudo install -d -m 700 /srv/postiz/postgres /srv/postiz/temporal-postgres
docker compose up -d postiz-postgres
sleep 30
gunzip -c /srv/postiz/backups/postiz-db-$(date +%F).sql.gz | docker compose exec -T postiz-postgres psql -U postiz -d postiz
docker compose up -d
sleep 120
curl -sS https://<DOMAIN>/api/auth/can-register
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `{"register":false}` from
the last command, which means your account survived a database that was deleted and rebuilt.

If you do not: `role "postiz" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. The last `sleep 120` is there
because Temporal is rebuilding its schemas from scratch too; if the final command answers
nothing, wait and run it again before concluding anything. Understand what this protects:
your drafts and your calendar come back from that dump, and a connected channel comes back
only if that network's token has not expired in the meantime.

## 9. Updating later

New versions are listed at https://github.com/gitroomhq/postiz-app/releases. Take both
backup artifacts first, then edit the `image:` line in /srv/postiz/compose.yml to the new
tag and its digest.

```bash
cd /srv/postiz
docker compose pull
docker compose up -d
docker compose logs --tail 30 postiz
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run
the four checks from step 7 before you call the update done. Read the release notes rather
than only the tag: which services this stack needs has changed before, and Temporal
arriving in v2.12.0 is the example.

## 10. What will probably go wrong

The first four minutes look like a broken install. I watched https://<DOMAIN>/api/ return
`502` over and over while `docker compose ps` showed every container up, and went hunting a
Caddy mistake that was not there. Temporal was still building its schemas, and Postiz
answers nothing until it can reach Temporal. Watch `docker compose logs -f temporal`, then
the Postiz log for its migrations, and give step 7 its full ten minutes before touching
anything.

## 11. Out of scope

- Do not connect a social network yet. Each one needs an app registered in that company's
  own developer portal, a redirect URI of
  `https://<DOMAIN>/integrations/social/`, and its client id and secret added to
  /srv/postiz/.env. Several of those registrations are reviewed by a person at the other
  company and take days, and no amount of waiting in this window changes that. Start at
  https://docs.postiz.com/providers/overview when the install is done.
- Do not configure SMTP or set `EMAIL_PROVIDER`. With no mail provider set upstream
  activates accounts without email, and this install relies on that.
- Do not install the Temporal web interface, the admin-tools container or Elasticsearch.
  Upstream ships all three; this stack runs without them and each is another service to
  watch.
- Do not set `OPENAI_API_KEY`, the Stripe keys, or `STORAGE_PROVIDER=cloudflare`. Each is
  an account somewhere else, and this install needs none of them.

348 lines · 14,991 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Postiz 2.23.0, with the PostgreSQL, Redis and Temporal it needs, under
~/selfhost/postiz, answering at http://localhost:8111.

## 1. Preflight

Say this before step 2 runs; it decides whether the user wants this install at all.
Posting to a network needs an app the user registers at that company's developer portal,
and each wants a redirect URI it can reach; here that is a localhost address some portals
reject. A post scheduled for 9am does not go out if the laptop is asleep.

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux
the distribution ID and codename print next, for step 2. This stack needs 4096 MB of RAM
available and 20 GB free on the home disk, upstream's own guidance once PostgreSQL, Redis
and the workflow engine share a host. All four images are amd64 and arm64. If RAM is under
4096 MB or free disk is under 20 GB, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/postiz/backups ~/selfhost/postiz/config ~/selfhost/postiz/uploads
ls -la ~/selfhost/postiz
```

Assert: three directories, all owned by the user. The databases and the cache live in
volumes Docker manages, so nothing here needs an ownership fix.

## 4. Secrets

Three: a password for each PostgreSQL, and the key that signs session tokens. Generate them
here, print none of them, and keep all three out of your summary and every log line.

```bash
umask 077
cat > ~/selfhost/postiz/.env <<EOF
POSTIZ_DB_PASSWORD=$(openssl rand -hex 32)
TEMPORAL_DB_PASSWORD=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 48)
EOF
chmod 600 ~/selfhost/postiz/.env
umask 022
ls -l ~/selfhost/postiz/.env
```

Assert: mode `-rw-------`. Compose reads this file from the working directory, so every
command from here runs with ~/selfhost/postiz as that directory. On Windows those mode bits
are advisory: NTFS does not enforce them, and the boundary is the user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/postiz/compose.yml <<'EOF'
# Postiz · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   compose install ....... https://docs.postiz.com/installation/docker-compose
#   variable reference .... https://docs.postiz.com/configuration/reference
#   system requirements ... https://docs.postiz.com/installation/system-requirements
#   temporal, sql only .... https://github.com/temporalio/docker-compose/blob/main/docker-compose-postgres.yml
#
# Five services on the computer you are sitting at, every path relative to
# ~/selfhost/postiz/ so one file works on macOS, Linux and Windows. Temporal has
# been required since v2.12.0 and keeps its workflow history in a database of
# its own, so the two PostgreSQL services differ. Three named volumes, because
# PostgreSQL and Redis chown their data directory to a uid of their own and
# Docker Desktop cannot grant that on a Windows home-directory bind mount;
# uploads and config stay relative binds. Digests read 2026-08-06, multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: postiz

services:
  postiz-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: postiz
      POSTGRES_USER: postiz
      POSTGRES_PASSWORD: ${POSTIZ_DB_PASSWORD}
    volumes:
      - postiz-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postiz -d postiz"]
      interval: 10s
      retries: 12
    # No `ports:`: 5432 is reachable only from the other containers.

  postiz-redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - postiz-redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  temporal-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_USER: temporal
      POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD}
    volumes:
      - temporal-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U temporal"]
      interval: 10s
      retries: 12

  temporal:
    image: temporalio/auto-setup:1.28.1@sha256:607d68caa111338d754771efb876c92dfcdae06d056e4530bb31cd0f37406e6a
    restart: unless-stopped
    environment:
      # postgres12 names the driver, not a version floor.
      DB: postgres12
      DB_PORT: "5432"
      POSTGRES_USER: temporal
      POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD}
      POSTGRES_SEEDS: temporal-postgres
    # No dynamic-config mount: the image ships its own, and Postiz overrides
    # nothing in it.
    healthcheck:
      test: ["CMD", "temporal", "operator", "cluster", "health", "--address", "temporal:7233"]
      interval: 10s
      retries: 30
    depends_on:
      temporal-postgres:
        condition: service_healthy

  postiz:
    image: ghcr.io/gitroomhq/postiz-app:v2.23.0@sha256:785f97312f66a347fb96cdccc4ded5a33ced69a672c89a9adc8054e7d6a21dc5
    restart: unless-stopped
    environment:
      # /api because the container's nginx routes /api/ to the backend.
      FRONTEND_URL: "http://localhost:8111"
      NEXT_PUBLIC_BACKEND_URL: "http://localhost:8111/api"
      BACKEND_INTERNAL_URL: "http://localhost:3000"
      DATABASE_URL: "postgresql://postiz:${POSTIZ_DB_PASSWORD}@postiz-postgres:5432/postiz"
      REDIS_URL: "redis://postiz-redis:6379"
      JWT_SECRET: ${JWT_SECRET}
      TEMPORAL_ADDRESS: "temporal:7233"
      # RUN_CRON registers the workflows that post on a schedule.
      IS_GENERAL: "true"
      RUN_CRON: "true"
      # One signup while the database is empty, then the page shuts.
      DISABLE_REGISTRATION: "true"
      STORAGE_PROVIDER: "local"
      UPLOAD_DIRECTORY: "/uploads"
      NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY: "/uploads"
    volumes:
      - ./config:/config
      - ./uploads:/uploads
    ports:
      # Loopback only: no other device on the wifi can reach 8111.
      - "127.0.0.1:8111:5000"
    depends_on:
      postiz-postgres:
        condition: service_healthy
      postiz-redis:
        condition: service_healthy
      temporal:
        condition: service_healthy

volumes:
  postiz-pgdata:
  postiz-redisdata:
  temporal-pgdata:
EOF
cd ~/selfhost/postiz && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK` and nothing else.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. There is no hostname to resolve, and a
certificate attests a public name that nothing here has; browsers treat http://localhost as
a secure context anyway, so pages needing crypto still work.

The one published line in compose.yml is `- "127.0.0.1:8111:5000"`: not the user's phone,
not a laptop on the same wifi, not anyone on the internet. Nothing else publishes a host
port, so 5432, 6379 and 7233 cannot appear.

## 7. Start and verify

The first start is slow: Temporal builds two schemas, then Postiz migrates.

```bash
cd ~/selfhost/postiz
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8111/api/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
docker compose exec -T temporal temporal operator cluster health --address temporal:7233
curl -sS http://localhost:8111/api/
curl -sS http://localhost:8111/api/auth/can-register
```

Assert all four, printing what you received for each: the loop ends on `200`, the Temporal
check prints `SERVING`, the third prints `App is running!`, the fourth prints
`{"register":true}` because the database holds no account yet. If any misses, stop, run
`docker compose logs --tail 40 postiz` and `docker compose logs --tail 20 temporal`, and
name the likely cause: a Temporal container stuck below healthy points at step 4, where an
empty password leaves its PostgreSQL refusing connections. A running container is not
success.

The first screen is http://localhost:8111/auth: the heading `Sign Up` over an email,
password and company form, with a `Create Account` button.

STOP: tell the user to open http://localhost:8111/auth, create the one account this install
will have, and wait. Do not continue until they confirm. Then check the window shut:

```bash
curl -sS http://localhost:8111/api/auth/can-register
```

Assert: `{"register":false}`. Upstream documents `DISABLE_REGISTRATION`, which compose.yml
sets, as allowing one signup and then closing the sign-up page. Have them reload the page
and confirm it reads `Registration is disabled`. Both pass before you claim success.

## 8. First backup and restore

Two artifacts: the dump holds accounts, drafts and the schedule, the archive holds the
files that rebuild the service around it, media included.

```bash
cd ~/selfhost/postiz
docker compose exec -T postiz-postgres pg_dump -U postiz -d postiz | gzip > ~/selfhost/postiz/backups/postiz-db-$(date +%F).sql.gz
tar -C ~/selfhost/postiz -czf ~/selfhost/postiz/backups/postiz-config-$(date +%F).tar.gz compose.yml .env config uploads
ls -lh ~/selfhost/postiz/backups/
```

Assert: both exist and are non-empty. Print both sizes. Nothing stops: `pg_dump` snapshots
a running database consistently, and Temporal's database is in neither: auto-setup rebuilds
it.

Both sit on the same disk as the data, which is not a backup, and on a laptop the disk and
the machine fail together. Ask the user for a destination off this computer, a sync folder
or a USB stick, and copy both there with `cp`. Assert: the user confirms both are
listed there.

To restore, in this order. `cd ~/selfhost/postiz`, untar the archive there first so
compose.yml and .env are back before any container starts, because PostgreSQL takes its
password from .env the moment it initialises an empty volume. Then `docker compose down -v`,
`docker compose up -d postiz-postgres`, wait 30 seconds for healthy, pipe `gunzip -c` on
the `.sql.gz` into `docker compose exec -T postiz-postgres psql -U postiz -d postiz`, then
`docker compose up -d`.

## 9. Updating later

New versions are listed at https://github.com/gitroomhq/postiz-app/releases. Take both
backups first, then edit the image line in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/postiz
docker compose pull
docker compose up -d
docker compose logs --tail 30 postiz
```

Watch that log until it settles, then re-run the four checks from step 7.

## 10. What will probably go wrong

I closed the lid on a Tuesday evening with a post scheduled for Wednesday morning, and on
Wednesday afternoon it was still in the calendar. Nothing was broken: a scheduled post is a
Temporal workflow, and a sleeping machine runs no containers. Docker Desktop not starting
after a reboot gives the same silence. Run `docker compose ps` before concluding
anything.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not connect a social network, and do not create developer accounts or apps for the
  user. Each needs an app registered in that company's own developer portal and its keys
  added to ~/selfhost/postiz/.env, several are reviewed by a person and take days, and here
  the redirect URI is a localhost address some portals reject. Say that in your summary,
  with https://docs.postiz.com/providers/overview.
compose.local.ymlthe services, pinned · local layout117 lines

authored from upstream docs, never pasted · 4,501 bytes

# Postiz · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   compose install ....... https://docs.postiz.com/installation/docker-compose
#   variable reference .... https://docs.postiz.com/configuration/reference
#   system requirements ... https://docs.postiz.com/installation/system-requirements
#   temporal, sql only .... https://github.com/temporalio/docker-compose/blob/main/docker-compose-postgres.yml
#
# Five services on the computer you are sitting at, every path relative to
# ~/selfhost/postiz/ so one file works on macOS, Linux and Windows. Temporal has
# been required since v2.12.0 and keeps its workflow history in a database of
# its own, so the two PostgreSQL services differ. Three named volumes, because
# PostgreSQL and Redis chown their data directory to a uid of their own and
# Docker Desktop cannot grant that on a Windows home-directory bind mount;
# uploads and config stay relative binds. Digests read 2026-08-06, multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: postiz

services:
  postiz-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: postiz
      POSTGRES_USER: postiz
      POSTGRES_PASSWORD: ${POSTIZ_DB_PASSWORD}
    volumes:
      - postiz-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postiz -d postiz"]
      interval: 10s
      retries: 12
    # No `ports:`: 5432 is reachable only from the other containers.

  postiz-redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - postiz-redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  temporal-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_USER: temporal
      POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD}
    volumes:
      - temporal-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U temporal"]
      interval: 10s
      retries: 12

  temporal:
    image: temporalio/auto-setup:1.28.1@sha256:607d68caa111338d754771efb876c92dfcdae06d056e4530bb31cd0f37406e6a
    restart: unless-stopped
    environment:
      # postgres12 names the driver, not a version floor.
      DB: postgres12
      DB_PORT: "5432"
      POSTGRES_USER: temporal
      POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD}
      POSTGRES_SEEDS: temporal-postgres
    # No dynamic-config mount: the image ships its own, and Postiz overrides
    # nothing in it.
    healthcheck:
      test: ["CMD", "temporal", "operator", "cluster", "health", "--address", "temporal:7233"]
      interval: 10s
      retries: 30
    depends_on:
      temporal-postgres:
        condition: service_healthy

  postiz:
    image: ghcr.io/gitroomhq/postiz-app:v2.23.0@sha256:785f97312f66a347fb96cdccc4ded5a33ced69a672c89a9adc8054e7d6a21dc5
    restart: unless-stopped
    environment:
      # /api because the container's nginx routes /api/ to the backend.
      FRONTEND_URL: "http://localhost:8111"
      NEXT_PUBLIC_BACKEND_URL: "http://localhost:8111/api"
      BACKEND_INTERNAL_URL: "http://localhost:3000"
      DATABASE_URL: "postgresql://postiz:${POSTIZ_DB_PASSWORD}@postiz-postgres:5432/postiz"
      REDIS_URL: "redis://postiz-redis:6379"
      JWT_SECRET: ${JWT_SECRET}
      TEMPORAL_ADDRESS: "temporal:7233"
      # RUN_CRON registers the workflows that post on a schedule.
      IS_GENERAL: "true"
      RUN_CRON: "true"
      # One signup while the database is empty, then the page shuts.
      DISABLE_REGISTRATION: "true"
      STORAGE_PROVIDER: "local"
      UPLOAD_DIRECTORY: "/uploads"
      NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY: "/uploads"
    volumes:
      - ./config:/config
      - ./uploads:/uploads
    ports:
      # Loopback only: no other device on the wifi can reach 8111.
      - "127.0.0.1:8111:5000"
    depends_on:
      postiz-postgres:
        condition: service_healthy
      postiz-redis:
        condition: service_healthy
      temporal:
        condition: service_healthy

volumes:
  postiz-pgdata:
  postiz-redisdata:
  temporal-pgdata:

agent-readable mirror: /self-host/buffer.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned112 lines

authored from upstream docs, never pasted · 4,475 bytes

# Postiz · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose install ....... https://docs.postiz.com/installation/docker-compose
#   variable reference .... https://docs.postiz.com/configuration/reference
#   system requirements ... https://docs.postiz.com/installation/system-requirements
#   temporal, sql only .... https://github.com/temporalio/docker-compose/blob/main/docker-compose-postgres.yml
#
# Five services. Postiz runs its frontend, backend and orchestrator in one
# container behind an nginx on port 5000. Upstream has required Temporal since
# v2.12.0, and Temporal keeps workflow history in its own database, so the two
# PostgreSQL services differ: the first holds your posts, the second holds
# state you can throw away. Upstream also ships Elasticsearch, a Temporal web
# UI and admin-tools; Temporal's own PostgreSQL-only compose has none of them,
# so neither does this. Digests read 2026-08-06; all four are amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: postiz

services:
  postiz-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: postiz
      POSTGRES_USER: postiz
      POSTGRES_PASSWORD: ${POSTIZ_DB_PASSWORD}
    volumes:
      - /srv/postiz/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postiz -d postiz"]
      interval: 10s
      retries: 12
    # No `ports:`: 5432 is reachable only from the other containers.

  postiz-redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/postiz/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  temporal-postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_USER: temporal
      POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD}
    volumes:
      - /srv/postiz/temporal-postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U temporal"]
      interval: 10s
      retries: 12

  temporal:
    image: temporalio/auto-setup:1.28.1@sha256:607d68caa111338d754771efb876c92dfcdae06d056e4530bb31cd0f37406e6a
    restart: unless-stopped
    environment:
      # postgres12 names the driver, not a version floor.
      DB: postgres12
      DB_PORT: "5432"
      POSTGRES_USER: temporal
      POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD}
      POSTGRES_SEEDS: temporal-postgres
    # No dynamic-config mount: the image ships its own, and Postiz overrides
    # nothing in it.
    healthcheck:
      test: ["CMD", "temporal", "operator", "cluster", "health", "--address", "temporal:7233"]
      interval: 10s
      retries: 30
    depends_on:
      temporal-postgres:
        condition: service_healthy

  postiz:
    image: ghcr.io/gitroomhq/postiz-app:v2.23.0@sha256:785f97312f66a347fb96cdccc4ded5a33ced69a672c89a9adc8054e7d6a21dc5
    restart: unless-stopped
    environment:
      # /api because the container's nginx routes /api/ to the backend.
      FRONTEND_URL: "https://${POSTIZ_DOMAIN}"
      NEXT_PUBLIC_BACKEND_URL: "https://${POSTIZ_DOMAIN}/api"
      BACKEND_INTERNAL_URL: "http://localhost:3000"
      DATABASE_URL: "postgresql://postiz:${POSTIZ_DB_PASSWORD}@postiz-postgres:5432/postiz"
      REDIS_URL: "redis://postiz-redis:6379"
      JWT_SECRET: ${JWT_SECRET}
      TEMPORAL_ADDRESS: "temporal:7233"
      # RUN_CRON registers the workflows that post on a schedule.
      IS_GENERAL: "true"
      RUN_CRON: "true"
      # One signup while the database is empty, then the page shuts.
      DISABLE_REGISTRATION: "true"
      STORAGE_PROVIDER: "local"
      UPLOAD_DIRECTORY: "/uploads"
      NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY: "/uploads"
    volumes:
      - /srv/postiz/config:/config
      - /srv/postiz/uploads:/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8111.
      - "127.0.0.1:8111:5000"
    depends_on:
      postiz-postgres:
        condition: service_healthy
      postiz-redis:
        condition: service_healthy
      temporal:
        condition: service_healthy
Caddyfilethe hostname and TLS26 lines

authored from upstream docs, never pasted · 988 bytes

# Postiz · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://docs.postiz.com/reverse-proxies/caddy
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. It is also
# POSTIZ_DOMAIN in .env, and the host every OAuth redirect URI points back at.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting a channel bounces out to a provider.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8111 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall. One upstream serves both halves
	# of the app: the nginx inside the container sends /api/ to the backend and
	# everything else to the frontend.
	reverse_proxy 127.0.0.1:8111
}
install.shthe same install, no agent161 lines

authored from upstream docs, never pasted · 7,305 bytes

#!/usr/bin/env bash
# Postiz · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=postiz.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.postiz.com/installation/docker-compose
#   https://docs.postiz.com/configuration/reference
#   https://docs.postiz.com/installation/system-requirements
#   https://docs.postiz.com/reverse-proxies/caddy
#
# Three secrets are generated here, on this machine: a password for each of the
# two PostgreSQL services and the key that signs session tokens. All three go
# into /srv/postiz/.env with mode 600 and none is ever printed.
#
# DOMAIN_HOST is the host inside every OAuth redirect URI you will register at X,
# Meta, LinkedIn and every other network. Choose it once. Changing it later means
# editing each of those developer apps by hand.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/postiz}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. postiz.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; five containers want 4096 MB, and a machine sold as 4 GB rarely clears that: use 8 GB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/config" "$APP_DIR/uploads"
sudo install -d -m 700 "$APP_DIR/postgres" "$APP_DIR/temporal-postgres" "$APP_DIR/redis"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex rather than base64: two of the three travel inside connection strings.
# Read them later with
#   sudo cat /srv/postiz/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		POSTIZ_DOMAIN=${DOMAIN_HOST}
		POSTIZ_DB_PASSWORD=$(openssl rand -hex 32)
		TEMPORAL_DB_PASSWORD=$(openssl rand -hex 32)
		JWT_SECRET=$(openssl rand -hex 48)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-postiz"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8111, 5432, 6379 and 7233 are not among them ----

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8111, 5432, 6379 and 7233 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first start is the slow one: Temporal builds two database schemas before it
# reports healthy, and Postiz then runs its own migrations.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/ (up to ten minutes)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/api/ answered ${code:-nothing}. Check: docker compose logs --tail 40 postiz"

docker compose exec -T temporal temporal operator cluster health --address temporal:7233 | grep -q SERVING \
	|| die "the Temporal cluster is not SERVING. Check: docker compose logs --tail 40 temporal"

curl -sS "https://${DOMAIN_HOST}/api/" | grep -q 'App is running!' \
	|| die "/api/ answered 200 without the expected body. Check: docker compose logs --tail 40 postiz"

# The sign-up window must be open exactly once, and only while the database is
# empty. Upstream documents DISABLE_REGISTRATION as allowing a single signup and
# then disabling the sign-up page.
reg="$(curl -sS "https://${DOMAIN_HOST}/api/auth/can-register" || true)"
case "$reg" in
	*'"register":true'*) SIGNUP_OPEN=yes ;;
	*'"register":false'*) SIGNUP_OPEN=no ;;
	*) die "/api/auth/can-register answered '${reg}'. Stop and investigate." ;;
esac

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postiz-postgres pg_dump -U postiz -d postiz | gzip > "$APP_DIR/backups/postiz-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/postiz-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env config uploads -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/postiz-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Postiz is answering at https://${DOMAIN_HOST}/api/

	  1. Sign-up window open: ${SIGNUP_OPEN}. If it says yes, open
	       https://${DOMAIN_HOST}/auth
	     and create your account now. The first screen shows the heading
	     "Sign Up" and a "Create Account" button. That one signup is the only
	     one this install allows: DISABLE_REGISTRATION is true, so the page
	     closes itself afterwards. Confirm it with
	       curl -sS https://${DOMAIN_HOST}/api/auth/can-register
	     which should answer {"register":false} once your account exists.
	  2. No social network is connected, and this script cannot connect one.
	     Every network needs an app you register in that company's own developer
	     portal, with a redirect URI of
	       https://${DOMAIN_HOST}/integrations/social/
	     and its client id and secret added to $APP_DIR/.env. Several of those
	     registrations are reviewed by a person at the other company and take
	     days. Start at https://docs.postiz.com/providers/overview.
	  3. Your three secrets are in $APP_DIR/.env, mode 600. Read them with
	       sudo cat $APP_DIR/.env
	     They were not printed here.
	  4. First backup written to $APP_DIR/backups: a database dump and a config
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Buffer.

  • You become the developer, at every network. Buffer's price buys their standing with X, Meta, LinkedIn and TikTok. Self-hosting means you register an app in each of those portals yourself, paste its keys into a file, and keep them working. Some of those registrations need a verified business and a human reviewer at the other company, which takes days, and no prompt on this site can shorten that.
  • X in particular is the hard one. Its free API tier is thin, image upload still goes through the old v1 endpoint so the app has to be set up for OAuth 1.0a, and upstream documents a specific app type because the other one fails authentication outright. Read that page before you plan a launch around it.
  • This is a workflow stack, not one container. Since v2.12.0 upstream requires Temporal, so the install is Postiz, its PostgreSQL, its Redis, a Temporal server and the separate PostgreSQL Temporal keeps its history in. Five services on a 4 GB box, and on any given morning one of them is the one that is unhappy.
  • Tokens expire, quietly. Every connected channel is an OAuth token that a network can revoke, expire or invalidate when its terms change. Postiz refreshes what it can, but a channel that has gone stale looks exactly like a channel that is fine until a post does not appear, so check the calendar after anything you actually care about.
  • No aggregator support desk. When a platform changes an endpoint or suspends your app, there is nobody to escalate to. That is the whole trade: no per-channel bill, and no phone number.

Where this came from

“Please note that no providers are configured by default. You will need to configure them all in your .env file, or as environment variables.”

  • Postiz has required a Temporal workflow cluster since v2.12.0, and upstream's own compose file ships PostgreSQL, Redis and Temporal pre-wired. source
  • No social providers are configured by default: every network needs its own client id and secret set as environment variables before Postiz can post to it. source
  • Connecting X means creating an app in the X developer portal, setting it to Read and Write and to Native App type, and copying the consumer key and secret into the environment. source
  • DISABLE_REGISTRATION allows a single user signup and then disables the sign-up page, which is how a self-hosted install closes its own front door. source
  • Behind a reverse proxy, FRONTEND_URL and NEXT_PUBLIC_BACKEND_URL are set to the public hostname with the backend at /api, and one container port is what the proxy sits in front of. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Buffer?

    Not Buffer itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Postiz. One calendar for every social account, on your server, with the network API keys registered in your own name. The install is ongoing ops: 5 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 300 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Buffer?

    Postiz. One calendar for every social account, on your server, with the network API keys registered in your own name. The only one here that is both actively developed and honest about the trade. You get the calendar, the per-network post variants, the media library and a public API, on AGPL-3.0, with no channel count and no per-channel bill. What you take on is the thing Buffer's price actually buys: every network you post to needs a developer app you register yourself, some of those registrations are reviewed by a human at Meta or X and take days, and when a platform changes its terms you are the one who reads the email. Budget a weekend for the stack and a second sitting for the paperwork. Postiz is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Buffer?

    4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Postiz itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Buffer Team, $10/mo — $120 a year, a metered rate, not a whole bill.

  • How hard is it really?

    ONGOING OPS — 3+ hours, then ongoing. The rule that produced that verdict: five or more containers. Five or more services is a stack. On any given morning one of them is unhappy, and you are the only person who is going to notice. The tier is derived from seven countable facts about the Postiz install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Postiz on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Postiz on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Everything answers at http://localhost:8111, so a post scheduled for 9am goes out only if the computer is awake at 9am, and the redirect URI you hand a developer portal is a localhost address that several of them refuse. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.