# Can I self-host Buffer?

**YES, IF** — it's called Postiz. ONGOING OPS setup · ~5 hours to running · 4 GB RAM minimum · $10/mo you stop paying ($120/yr on the Team plan) — a metered rate, not a whole bill.

Postiz authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/buffer/

## Install prompt (Claude Code)

````text
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`.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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
```

The page this mirrors: https://caniselfhostit.com/self-host/buffer/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
