Can I self-host Slack?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Mattermost. It takes one prompt, a 2048 MB VPS, and about 110 minutes. That is $87.50 a month you stop paying Slack — $1,050 a year on the Pro plan, 10 seats assumed.

  • slack.com
  • Comms & scheduling
  • prices checked 2026-08-06

Why people pay for Slack

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.

Slack sells the absence of friction around a conversation. The apps are on every device anyone already owns, the search finds the message from eighteen months ago, and the integration your finance team wants was built by the vendor rather than by you. The free plan hides its own bill: history disappears after ninety days, so the archive people actually rely on is the thing the paid tiers unlock.

Slack plans and list prices
PlanList priceWhat it buys
FreefreeNo per-user cost. Message and file history is limited to the most recent 90 days.
Prothe plan this page prices against$8.75/mo per seatPer active user per month, billed monthly. $7.25 per user per month if billed annually.
Business+$18/mo per seatPer active user per month, billed monthly. $15 per user per month if billed annually.
Enterprise+quote onlyQuote only. The page says contact sales for pricing.

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

Replaced by Mattermost

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

Channels, threads and file sharing for a team, on a server you own, with no per-seat meter and no ninety-day history cliff.

The closest thing to a like-for-like swap. Channels, threads, mentions, file sharing and search behave the way a Slack team expects, the official desktop and mobile apps are real apps rather than a browser tab, and the message history that Slack's free plan deletes after ninety days is rows in a PostgreSQL you own. What you take on is a server with a certificate on it, and a licence ladder of its own: the free Team Edition stops short of SSO, guest accounts and compliance export.

The swap

You're paying

Slack

$87.50/mo · $1,050/yr

is replaced by

You'd run

Mattermost

ONE EVENING · ~110 min to running · 2048 MB RAM

Slack Pro · 10 seats assumed · vendor list price · checked 2026-08-06 · source

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 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
~110 min1–3 hours, through the first backup

The prompt

Two paths to the same Mattermost: 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

312 lines · 14,406 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 Mattermost 11.9.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.
Its A record must already point at this server. That hostname becomes
`MM_SERVICESETTINGS_SITEURL`, and every invite link and permalink is built from it.

Mattermost needs 2048 MB of RAM available and 10 GB free on /srv. Upstream supports 64-bit x86
processors and publishes the Team Edition image for amd64 only, so this install refuses an arm64
box rather than emulating one. Measure all four:

```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 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dpkg --print-architecture` prints anything other than `amd64`, print it
and stop: there is no arm64 image to pull. If `dig +short` prints nothing, print that and stop,
because Caddy cannot get a certificate for a name that does not resolve.

## 2. Layout

The server image is distroless and runs as uid 2000, and writes to four directories. Create those
with that owner and leave the PostgreSQL directory alone: the database image chowns its own
cluster on first start.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/mattermost /srv/mattermost/backups
sudo install -d -m 750 -o 2000 -g 2000 /srv/mattermost/config /srv/mattermost/data /srv/mattermost/plugins /srv/mattermost/client-plugins
sudo install -d -m 700 /srv/mattermost/postgres
ls -la /srv/mattermost
```

Assert: `ls -la` shows `backups` owned by the login user, four directories owned by `2000`, and
`postgres` at mode `700` owned by root. If `config` belongs to anyone else the container exits
saying it cannot load its configuration, and that log line never mentions ownership.

## 3. Secrets

One secret: the PostgreSQL password. Generate it on the server. Do not print it, do not repeat it
in your summary, and do not put it in any log line. Hex rather than base64, because it is pasted
into a connection string where `+` and `/` would need escaping.

```bash
umask 077
cat > /srv/mattermost/.env <<EOF
DOMAIN=<DOMAIN>
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/mattermost/.env
umask 022
ls -l /srv/mattermost/.env
```

Assert: the file exists with mode `-rw-------`. Replace `<DOMAIN>` on the first line with the real
hostname before you write it. Compose reads this file for both `${DOMAIN}` and `${DB_PASSWORD}`,
so no secret is written into compose.yml. Mattermost writes its own at-rest encryption key and
public-link salt into `config/config.json` on first start, so there is nothing else to make here.

## 4. compose.yml

```bash
cat > /srv/mattermost/compose.yml <<'EOF'
# Mattermost · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   container deploy ... https://docs.mattermost.com/deployment-guide/server/deploy-containers.html
#   variable reference . https://github.com/mattermost/docker/blob/main/env.example
#
# Two services: Mattermost and the PostgreSQL that holds every message. The
# image is the Team Edition build, the compiled edition Mattermost, Inc.
# licenses under MIT; upstream's own compose file reaches for the enterprise
# build instead and runs it with no licence key. That image is distroless and
# runs as uid 2000, so the four directories it writes are made with that owner
# in step 2, while PostgreSQL 18 chowns its own cluster on first start.
# Upstream's /mattermost/logs mount is left out because `docker compose logs`
# already reads the console log, and an unrotated second copy on the same disk
# is a slow disk-full rather than a feature. Digests read 2026-08-06;
# PostgreSQL publishes amd64 and arm64, Mattermost only amd64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: mattermost-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: mattermost
      POSTGRES_USER: mmuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/mattermost/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mmuser -d mattermost"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 never leaves the compose network.

  mattermost:
    image: mattermost/mattermost-team-edition:11.9.0@sha256:1c538cf33c2144ba2c825571cd414aaaebf8d8c231d4b18081b811cd0ca0ef2a
    container_name: mattermost
    platform: linux/amd64
    restart: unless-stopped
    environment:
      MM_SQLSETTINGS_DRIVERNAME: postgres
      MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:${DB_PASSWORD}@postgres:5432/mattermost?sslmode=disable&connect_timeout=10"
      # Caddy terminates TLS, so this says https although the container
      # speaks plain http. Every invite link is built from it.
      MM_SERVICESETTINGS_SITEURL: https://${DOMAIN}
      # Anonymous signup stays shut; the server exempts the first account.
      MM_TEAMSETTINGS_ENABLEOPENSERVER: "false"
      # No SMTP and no telemetry: no mail, nothing reported to Sentry.
      MM_EMAILSETTINGS_SENDEMAILNOTIFICATIONS: "false"
      MM_LOGSETTINGS_ENABLEDIAGNOSTICS: "false"
    volumes:
      - /srv/mattermost/config:/mattermost/config
      - /srv/mattermost/data:/mattermost/data
      - /srv/mattermost/plugins:/mattermost/plugins
      - /srv/mattermost/client-plugins:/mattermost/client/plugins
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8113.
      - "127.0.0.1:8113:8065"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/mattermost && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. A setting given as an `MM_` environment variable overrides
config.json and shows greyed out in the System Console, so the four hardening choices above
cannot be undone from a browser session.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, 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-mattermost
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Mattermost · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.mattermost.com/deployment-guide/server/deploy-containers.html 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. That hostname is
# also MM_SERVICESETTINGS_SITEURL in compose.yml, and Mattermost builds every
# invite link and websocket address from it, so the two have to agree.

<DOMAIN> {
	encode zstd gzip

	# This server holds a team's messages, so nothing here should be sniffed
	# or handed to another site in a referrer. X-Frame-Options is absent on
	# purpose: Mattermost sets SAMEORIGIN and its own Content-Security-Policy,
	# and one set here would overwrite the application's answer.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8113 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. The webapp holds a
	# websocket open at /api/v4/websocket for as long as it is on screen, and
	# Caddy's reverse_proxy performs that upgrade with no extra directive.
	reverse_proxy 127.0.0.1:8113
}
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-mattermost, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own; nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change nothing:

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

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and 443/udp
is HTTP/3. 8113 stays closed because it is bound to 127.0.0.1, and 5432 stays closed because
compose never publishes it. Assert: `ufw status verbose` prints `Status: active`, shows 80,
443/tcp and 443/udp, and no rule for 8113 or 5432.

## 7. Start and verify

Mattermost runs its own database migrations on the way up. The first start is much slower than
the second.

```bash
cd /srv/mattermost
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v4/system/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v4/system/ping
curl -sS https://<DOMAIN>/api/v4/config/client | tr ',' '\n' | grep NoAccounts
```

Assert, all three: the loop ends printing `200`; the ping response contains `"status":"OK"`; the
last line prints `"NoAccounts":"true"`, the server saying it has no users yet. Print what you
received for each. If any of the three misses, stop, run
`docker compose logs --tail 40 mattermost` and `docker compose logs --tail 20 postgres`, and
name the likely cause: a database that never reports healthy points at step 3, and a Mattermost
log that cannot load its configuration points at the ownership of /srv/mattermost/config in step
2. A running container is not success.

The first screen at https://<DOMAIN> is the account form, headed `Create your account`. The first
account made here becomes the system administrator, and until it exists that form is open to
anyone who loads the page.

STOP: tell the user to open https://<DOMAIN> now, create their account, and put that password in
their password manager, then wait. Do not continue until they confirm. There is no mail in this
install, so a forgotten administrator password has no reset link.

Once they confirm, prove the window is shut:

```bash
curl -sS https://<DOMAIN>/api/v4/config/client | tr ',' '\n' | grep NoAccounts
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"email":"signup-check@example.invalid","username":"signupcheck"}' https://<DOMAIN>/api/v4/users
```

Assert: the first prints `"NoAccounts":"false"`, and the second prints `403`, the server refusing
an anonymous signup now that an account exists. Both must pass before you report success.

## 8. First backup and restore

Two artifacts. The database holds every message, channel and account. The file archive holds the
uploads, the generated config and the pieces that rebuild the service around them.

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

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped: `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/mattermost
scp vps:/srv/mattermost/backups/* ~/backups/mattermost/
```

To restore: `docker compose down`, `sudo rm -rf /srv/mattermost/postgres`, recreate it as in step
2, `docker compose up -d postgres`, wait about 30 seconds for it to report healthy, pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T postgres psql -U mmuser -d mattermost`,
untar the file archive into /srv/mattermost, then `docker compose up -d`. Tell the user what
is at stake: a database without the `data` directory is every message with every attachment
broken, and `config` carries the at-rest encryption key the server made on day one.

## 9. Updating later

New versions are listed at https://github.com/mattermost/mattermost/releases. Mattermost also
publishes an extended-support line that takes patches for longer; this tag is the current feature
release, not that one. Take both backups first, then edit the image line in
/srv/mattermost/compose.yml to the new tag and its digest:

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

Watch that log until the migrations settle, then re-run step 7's ping check before calling the
update done. Move one major version at a time.

## 10. What will probably go wrong

The gap between the container starting and the user creating their account is a real hole, and I
walked away during it. Caddy publishes the hostname to the certificate transparency logs the
moment it issues, those logs are scraped within seconds, and while `NoAccounts` reads `true` the
first stranger to load that page becomes the system administrator. Nothing happened to me, but
nothing had to. Do not run step 7 until the user is at their keyboard, and do not treat that
pause as a formality.

## 11. Out of scope

- Do not configure SMTP. Mattermost runs without it and invitations work as copyable links from
  the Invite People dialog. Say plainly that password reset stays broken until mail exists.
- Do not enable AD/LDAP, SAML or OpenID sign-on. Those are licensed features of the paid
  editions, and turning them on here produces an error rather than a login page.
- Do not install the Calls, Playbooks or any other plugin. Each is a separate decision with its
  own ports and storage; this prompt installs the chat server.
- Do not switch the image to mattermost-enterprise-edition. That is a different licence, and this
  install chose Team Edition on purpose.
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 Mattermost 11.9.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>` becomes `MM_SERVICESETTINGS_SITEURL`, and Mattermost builds
every invite link, permalink and websocket address from it. Changing it later means editing the
config, restarting, and reissuing invitations, so 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 `2048` MB available, at least `10` G free, `amd64`, and your server's
IP on the last line.

If you do not: `arm64` on the third line is the end of this install, not a hurdle. Upstream
supports 64-bit x86 processors and publishes the Mattermost image for amd64 only, so there is
nothing to pull on an ARM box. An empty last line means the A record does not exist yet: add it,
wait a minute, run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a
hostname that does not resolve and failed attempts count against a rate limit you cannot see.

## 2. Layout

The server image is distroless and runs as uid 2000, and it writes to four directories.
PostgreSQL chowns its own cluster on first start, so that one stays root-owned.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/mattermost /srv/mattermost/backups
sudo install -d -m 750 -o 2000 -g 2000 /srv/mattermost/config /srv/mattermost/data /srv/mattermost/plugins /srv/mattermost/client-plugins
sudo install -d -m 700 /srv/mattermost/postgres
ls -la /srv/mattermost
```

You should see: `backups` owned by you, four directories owned by `2000`, and `postgres` at
mode `drwx------` owned by root.

If you do not: do not chown any of them to yourself to make an error go away. A `config`
directory the container cannot write is the single most common way this install fails, and it
fails with a log line about loading configuration that never mentions ownership. Leave
`postgres` owned by root: the PostgreSQL image chowns its own data directory the first time it
starts, and one you have already chowned makes it refuse to initialise.

## 3. Secrets

One secret: the PostgreSQL password. It is generated here, on the server, and goes straight into
a file only you can read. Hex rather than base64, because it is pasted into a connection string
where `+` and `/` would need escaping.

```bash
umask 077
cat > /srv/mattermost/.env <<EOF
DOMAIN=<DOMAIN>
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/mattermost/.env
umask 022
ls -l /srv/mattermost/.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.

Do not paste that file, the password, or any command output containing it into this chat window.
The other tab never sees those values; this one hands them to a third party unless you stop it.

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/mattermost/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten the
password, which is fine before the database exists and a problem afterwards: PostgreSQL keeps the
password it was created with, so a changed `DB_PASSWORD` on an existing volume shows up as an
authentication failure in the Mattermost log rather than as anything about passwords.

Mattermost generates its own at-rest encryption key and public-link salt into
`config/config.json` the first time it starts, so this is the only secret you create by hand.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/mattermost/compose.yml <<'EOF'
# Mattermost · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   container deploy ... https://docs.mattermost.com/deployment-guide/server/deploy-containers.html
#   variable reference . https://github.com/mattermost/docker/blob/main/env.example
#
# Two services: Mattermost and the PostgreSQL that holds every message. The
# image is the Team Edition build, the compiled edition Mattermost, Inc.
# licenses under MIT; upstream's own compose file reaches for the enterprise
# build instead and runs it with no licence key. That image is distroless and
# runs as uid 2000, so the four directories it writes are made with that owner
# in step 2, while PostgreSQL 18 chowns its own cluster on first start.
# Upstream's /mattermost/logs mount is left out because `docker compose logs`
# already reads the console log, and an unrotated second copy on the same disk
# is a slow disk-full rather than a feature. Digests read 2026-08-06;
# PostgreSQL publishes amd64 and arm64, Mattermost only amd64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: mattermost-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: mattermost
      POSTGRES_USER: mmuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/mattermost/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mmuser -d mattermost"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 never leaves the compose network.

  mattermost:
    image: mattermost/mattermost-team-edition:11.9.0@sha256:1c538cf33c2144ba2c825571cd414aaaebf8d8c231d4b18081b811cd0ca0ef2a
    container_name: mattermost
    platform: linux/amd64
    restart: unless-stopped
    environment:
      MM_SQLSETTINGS_DRIVERNAME: postgres
      MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:${DB_PASSWORD}@postgres:5432/mattermost?sslmode=disable&connect_timeout=10"
      # Caddy terminates TLS, so this says https although the container
      # speaks plain http. Every invite link is built from it.
      MM_SERVICESETTINGS_SITEURL: https://${DOMAIN}
      # Anonymous signup stays shut; the server exempts the first account.
      MM_TEAMSETTINGS_ENABLEOPENSERVER: "false"
      # No SMTP and no telemetry: no mail, nothing reported to Sentry.
      MM_EMAILSETTINGS_SENDEMAILNOTIFICATIONS: "false"
      MM_LOGSETTINGS_ENABLEDIAGNOSTICS: "false"
    volumes:
      - /srv/mattermost/config:/mattermost/config
      - /srv/mattermost/data:/mattermost/data
      - /srv/mattermost/plugins:/mattermost/plugins
      - /srv/mattermost/client-plugins:/mattermost/client/plugins
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8113.
      - "127.0.0.1:8113:8065"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/mattermost && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/mattermost/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/mattermost/compose.yml` and paste again in one go. A warning that the variable
`DB_PASSWORD` is not set means you are running the command from somewhere other than
/srv/mattermost, which is where compose looks for the `.env` it reads.

Every setting given as an `MM_` environment variable overrides `config.json` and shows greyed out
in the System Console. That is deliberate: the four hardening choices in this file cannot be
undone from a browser session, only by editing this file.

## 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-mattermost
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Mattermost · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.mattermost.com/deployment-guide/server/deploy-containers.html 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. That hostname is
# also MM_SERVICESETTINGS_SITEURL in compose.yml, and Mattermost builds every
# invite link and websocket address from it, so the two have to agree.

<DOMAIN> {
	encode zstd gzip

	# This server holds a team's messages, so nothing here should be sniffed
	# or handed to another site in a referrer. X-Frame-Options is absent on
	# purpose: Mattermost sets SAMEORIGIN and its own Content-Security-Policy,
	# and one set here would overwrite the application's answer.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8113 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. The webapp holds a
	# websocket open at /api/v4/websocket for as long as it is on screen, and
	# Caddy's reverse_proxy performs that upgrade with no extra directive.
	reverse_proxy 127.0.0.1:8113
}
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-mattermost /etc/caddy/Caddyfile`, reload,
and paste again. There is nothing to add for the websocket the webapp keeps open at
`/api/v4/websocket`: Caddy's `reverse_proxy` performs that upgrade on its own, which is why this
block is short where the upstream nginx example is ninety lines.

## 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 `8113` or `5432`.

If you do not: delete anything for `8113` or `5432` with `sudo ufw delete allow 8113`. 8113 is
bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the database has no
host port a firewall rule could apply to. 80/tcp redirects to HTTPS and answers the ACME
challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`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

Mattermost runs its own database migrations on the way up. The first start is much slower than
the second, so the loop below is doing real work rather than being cautious.

Have your browser open before you paste this. The moment Caddy issues a certificate, that
hostname is in the public certificate transparency logs, and until you create the first account
anybody who loads the page can create it and become the system administrator.

```bash
cd /srv/mattermost
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v4/system/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v4/system/ping
curl -sS https://<DOMAIN>/api/v4/config/client | tr ',' '\n' | grep NoAccounts
```

You should see, in order: the loop climbing and ending on `200`, a JSON object containing
`"status":"OK"`, and then `"NoAccounts":"true"`.

If you do not: run `docker compose logs --tail 20 postgres` first, because a database that never
reports healthy stops everything behind it, then `docker compose logs --tail 40 mattermost`. A
Mattermost log that cannot load its configuration is step 2 done wrong, and the fix is
`sudo chown -R 2000:2000 /srv/mattermost/config` followed by `docker compose up -d`. A `502` that
never becomes `200` after five minutes is usually DNS: run `dig +short <DOMAIN>` and check the
certificate actually issued with `sudo journalctl -u caddy --since -10m`. A green
`docker compose ps` is not success on its own.

Now open https://<DOMAIN> in your browser. The first screen is the account form, headed
`Create your account`. Create your account, and put that password in your password manager: there
is no mail in this install, so a forgotten administrator password has no reset link and the only
way back in is the command line.

Then prove the window is shut:

```bash
curl -sS https://<DOMAIN>/api/v4/config/client | tr ',' '\n' | grep NoAccounts
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"email":"signup-check@example.invalid","username":"signupcheck"}' https://<DOMAIN>/api/v4/users
```

You should see: `"NoAccounts":"false"`, then `403`.

If you do not: a `201` in place of the `403` means that command created an account, which means
`MM_TEAMSETTINGS_ENABLEOPENSERVER` is not being read. Check that the line is in
/srv/mattermost/compose.yml, run `docker compose up -d --force-recreate`, delete the account it
created from the System Console, and run the check again. This is the assert with real security
meaning in the whole install; do not wave it through.

## 8. First backup and restore

Two artifacts. The database holds every message, channel and account. The file archive holds the
uploads, the config Mattermost generated, and the pieces that rebuild the service around them.

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

You should see: two files, both a few hundred kilobytes on a fresh install. Nothing goes offline:
`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/mattermost
scp vps:/srv/mattermost/backups/* ~/backups/mattermost/
```

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

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 an empty install:

```bash
cd /srv/mattermost
docker compose down
sudo rm -rf /srv/mattermost/postgres
sudo install -d -m 700 /srv/mattermost/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/mattermost/backups/mattermost-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U mmuser -d mattermost
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/api/v4/config/client | tr ',' '\n' | grep NoAccounts
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `"NoAccounts":"false"`, which
means your account survived a database that was deleted and rebuilt. Log in to confirm.

If you do not: `role "mmuser" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the two artifacts
are for before you skip this: the database without the `data` directory gives you every message
with every attachment broken, and the `config` directory carries the at-rest encryption key the
server generated on its first start.

## 9. Updating later

New versions are listed at https://github.com/mattermost/mattermost/releases. Mattermost also
publishes an extended-support line that takes patches for longer; the tag here is the current
feature release, not that one. Take both backup artifacts first, then edit the `image:` line in
/srv/mattermost/compose.yml to the new tag and its digest.

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

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. Move one major
version at a time, because upstream does not support skipping them, and re-run the ping check
from step 7 before you call the update done.

## 10. What will probably go wrong

The gap between the container starting and you creating your account is a real hole, and I walked
away during it. Caddy publishes the hostname to the certificate transparency logs the moment it
issues, those logs are scraped within seconds, and while `NoAccounts` reads `true` the first
stranger to load that page becomes the system administrator of your server. Nothing happened to
me, but nothing had to. Do not paste step 7 until you are ready to open the browser immediately
afterwards.

## 11. Out of scope

- Do not configure SMTP. Mattermost runs without it and invitations work as copyable links from
  the Invite People dialog. Password reset stays broken until mail exists, and that is the trade
  this install makes.
- Do not enable AD/LDAP, SAML or OpenID sign-on. Those are licensed features of the paid
  editions, and turning them on here produces an error rather than a login page.
- Do not install the Calls, Playbooks or any other plugin. Each is a separate decision with its
  own ports and storage; this install gives you the chat server.
- Do not switch the image to mattermost-enterprise-edition. That is a different licence and this
  install chose Team Edition on purpose.

318 lines · 14,979 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 Mattermost 11.9.0 and the PostgreSQL it stores every message in, under
~/selfhost/mattermost, answering at http://localhost:8113.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
Mattermost is a team chat server, and this one answers only at http://localhost:8113: this
computer and nowhere else. Nobody they invite can reach it, and neither can their own phone.

Detect the OS and measure the machine:

```bash
uname -s
uname -m
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. Mattermost plus
PostgreSQL needs 2048 MB of RAM available and 10 GB free on the home disk. If either is under,
print both numbers and stop. Do not install and hope.

Upstream publishes the Mattermost image for amd64 only. If `uname -m` printed `arm64`, this is an
Apple Silicon Mac and Docker Desktop runs the server under x86 translation: it works and starts
slowly. If it printed `aarch64`, this is arm64 Linux with no translation layer: print 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

The Mattermost image is distroless and runs as uid 2000, writing to four directories.

```bash
mkdir -p ~/selfhost/mattermost/{config,data,plugins,client-plugins,backups}
if [ "$(uname -s)" = "Linux" ]; then
  sudo chown -R 2000:2000 ~/selfhost/mattermost/{config,data,plugins,client-plugins}
fi
ls -la ~/selfhost/mattermost
```

Assert: `ls -la` shows all five directories. The chown is Linux-only: on macOS and Windows Docker
Desktop maps container writes onto the user's own account. There is no PostgreSQL directory,
because step 5 keeps that database in a Docker volume.

## 4. Secrets

One secret: the PostgreSQL password. Generate it here, print it nowhere, and keep it out of your
summary and out of any log line. Hex rather than base64, because it is pasted into a connection
string where `+` and `/` would need escaping.

```bash
umask 077
cat > ~/selfhost/mattermost/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/mattermost/.env
umask 022
ls -l ~/selfhost/mattermost/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same on
all three systems. Mattermost writes its own at-rest encryption key and public-link salt into
`config/config.json` on first start. On Windows those mode bits are advisory: NTFS does not
enforce them, and the user's own account is the real boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/mattermost/compose.yml <<'EOF'
# Mattermost · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   container deploy ... https://docs.mattermost.com/deployment-guide/server/deploy-containers.html
#   variable reference . https://github.com/mattermost/docker/blob/main/env.example
#
# Two services, every path relative to ~/selfhost/mattermost/ so one file works
# on macOS, Linux and Windows. PostgreSQL is a named volume because that image
# chowns its cluster to a uid Docker Desktop cannot grant on a Windows bind
# mount. Mattermost runs as uid 2000, so step 3 chowns its four bind mounts on
# Linux. Digests read 2026-08-06; only amd64 for Mattermost.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: mattermost-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: mattermost
      POSTGRES_USER: mmuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mattermost-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mmuser -d mattermost"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 never leaves the compose network.

  mattermost:
    image: mattermost/mattermost-team-edition:11.9.0@sha256:1c538cf33c2144ba2c825571cd414aaaebf8d8c231d4b18081b811cd0ca0ef2a
    container_name: mattermost
    platform: linux/amd64
    restart: unless-stopped
    environment:
      MM_SQLSETTINGS_DRIVERNAME: postgres
      MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:${DB_PASSWORD}@postgres:5432/mattermost?sslmode=disable&connect_timeout=10"
      # Nothing terminates TLS here, so every link says http and localhost.
      MM_SERVICESETTINGS_SITEURL: http://localhost:8113
      # Anonymous signup stays shut; the server exempts the first account.
      MM_TEAMSETTINGS_ENABLEOPENSERVER: "false"
      # No SMTP and no telemetry: no mail, nothing reported to Sentry.
      MM_EMAILSETTINGS_SENDEMAILNOTIFICATIONS: "false"
      MM_LOGSETTINGS_ENABLEDIAGNOSTICS: "false"
    volumes:
      - ./config:/mattermost/config
      - ./data:/mattermost/data
      - ./plugins:/mattermost/plugins
      - ./client-plugins:/mattermost/client/plugins
    ports:
      # Loopback only: no other device on the wifi can reach 8113.
      - "127.0.0.1:8113:8065"
    depends_on:
      postgres:
        condition: service_healthy

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

Assert: that prints `compose OK`. Two services, one published port, one volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so the webapp still works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8113 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. For a chat server that is the whole trade, which is why step 1
says it first. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/mattermost/compose.yml
```

Assert: one line, `- "127.0.0.1:8113:8065"`. PostgreSQL publishes no host port, so 5432 cannot
appear.

## 7. Start and verify

Mattermost runs its own migrations on the way up, so the first start is much slower than the
second, and slower again under x86 translation.

```bash
cd ~/selfhost/mattermost
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8113/api/v4/system/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8113/api/v4/system/ping
curl -sS http://localhost:8113/api/v4/config/client | tr ',' '\n' | grep NoAccounts
```

Assert all three, and print what you received for each: the loop ends on `200`; the ping response
contains `"status":"OK"`; the last line prints `"NoAccounts":"true"`, the server saying it has no
users yet. If any misses, stop, run `docker compose logs --tail 40 mattermost` and
`docker compose logs --tail 20 postgres`, and name the likely cause: a database that never
reports healthy points at step 4, where an empty `DB_PASSWORD` leaves PostgreSQL refusing to
start; a log that cannot load its configuration points at the ownership of `config` in step 3. If
`port is already allocated` came back, find what holds 8113 (`lsof -nP -iTCP:8113 -sTCP:LISTEN`)
and stop until the user frees it. A running container is not success.

The first screen at http://localhost:8113 is the account form, headed `Create your account`. The
first account made here becomes the system administrator.

STOP: tell the user to open http://localhost:8113, create their account, put that password in
their password manager, and wait. Do not continue until they confirm. There is no mail here, so a
forgotten password has no reset link.

Once they confirm:

```bash
curl -sS http://localhost:8113/api/v4/config/client | tr ',' '\n' | grep NoAccounts
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"email":"signup-check@example.invalid","username":"signupcheck"}' http://localhost:8113/api/v4/users
```

Assert: the first prints `"NoAccounts":"false"`, the second `403`, the server refusing an
anonymous signup now that an account exists. Both must pass before you report success.

## 8. First backup and restore

Two artifacts: a database dump with every message, channel and account, and a file archive with
the uploads and the generated config.

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

Assert: both files exist and are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently.

Both archives 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 that leaves this computer, a folder
their sync service watches or a USB stick, and copy both there with `cp`; in Git Bash a Windows
drive is written `/d/Backups`. Assert: the user confirms both filenames are listed there, and if
they have neither, say plainly that this install has no backup.

To restore, in this order. `cd ~/selfhost/mattermost`, untar the file archive there first so .env
is back before any container starts: PostgreSQL reads `DB_PASSWORD` the moment it initialises an
empty volume, and a missing .env means a blank password and a database that will not start. Then
`docker compose down -v`, the one place `-v` belongs because it drops the old volume on purpose,
`docker compose up -d postgres`, wait 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz`
into `docker compose exec -T postgres psql -U mmuser -d mattermost`, then `docker compose up -d`.
Log in and open a channel. That is the whole disaster plan.

## 9. Updating later

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

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

Watch that log until the migrations settle, then re-run step 7's ping check. Move one major
version at a time.

## 10. What will probably go wrong

I ran `docker compose up -d`, curled http://localhost:8113 straight away, got a connection reset,
and started undoing things. Nothing was broken: Mattermost was still on its first migration,
which took the better part of two minutes. Then I looked inside with
`docker compose exec mattermost sh` and got `executable file not found`, which is not a second
failure: the image is distroless and has no shell at all. Step 7's loop covers the first,
`docker compose logs -f mattermost` the second.

## 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 change `MM_SERVICESETTINGS_SITEURL` to a LAN address or rebind 8113 to 0.0.0.0 so a
  phone can reach it. That puts real messages on every network the user joins.
- Do not configure SMTP, and do not enable AD/LDAP, SAML or OpenID sign-on. Mattermost runs
  without mail, and the sign-on integrations are licensed features of the paid editions that
  answer with an error here rather than a login page.
- Do not switch to the mattermost-enterprise-edition image. That is a different licence and this
  install chose Team Edition on purpose.
compose.local.ymlthe services, pinned · local layout59 lines

authored from upstream docs, never pasted · 2,555 bytes

# Mattermost · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   container deploy ... https://docs.mattermost.com/deployment-guide/server/deploy-containers.html
#   variable reference . https://github.com/mattermost/docker/blob/main/env.example
#
# Two services, every path relative to ~/selfhost/mattermost/ so one file works
# on macOS, Linux and Windows. PostgreSQL is a named volume because that image
# chowns its cluster to a uid Docker Desktop cannot grant on a Windows bind
# mount. Mattermost runs as uid 2000, so step 3 chowns its four bind mounts on
# Linux. Digests read 2026-08-06; only amd64 for Mattermost.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: mattermost-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: mattermost
      POSTGRES_USER: mmuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mattermost-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mmuser -d mattermost"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 never leaves the compose network.

  mattermost:
    image: mattermost/mattermost-team-edition:11.9.0@sha256:1c538cf33c2144ba2c825571cd414aaaebf8d8c231d4b18081b811cd0ca0ef2a
    container_name: mattermost
    platform: linux/amd64
    restart: unless-stopped
    environment:
      MM_SQLSETTINGS_DRIVERNAME: postgres
      MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:${DB_PASSWORD}@postgres:5432/mattermost?sslmode=disable&connect_timeout=10"
      # Nothing terminates TLS here, so every link says http and localhost.
      MM_SERVICESETTINGS_SITEURL: http://localhost:8113
      # Anonymous signup stays shut; the server exempts the first account.
      MM_TEAMSETTINGS_ENABLEOPENSERVER: "false"
      # No SMTP and no telemetry: no mail, nothing reported to Sentry.
      MM_EMAILSETTINGS_SENDEMAILNOTIFICATIONS: "false"
      MM_LOGSETTINGS_ENABLEDIAGNOSTICS: "false"
    volumes:
      - ./config:/mattermost/config
      - ./data:/mattermost/data
      - ./plugins:/mattermost/plugins
      - ./client-plugins:/mattermost/client/plugins
    ports:
      # Loopback only: no other device on the wifi can reach 8113.
      - "127.0.0.1:8113:8065"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  mattermost-pgdata:

agent-readable mirror: /self-host/slack.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, pinned62 lines

authored from upstream docs, never pasted · 3,003 bytes

# Mattermost · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   container deploy ... https://docs.mattermost.com/deployment-guide/server/deploy-containers.html
#   variable reference . https://github.com/mattermost/docker/blob/main/env.example
#
# Two services: Mattermost and the PostgreSQL that holds every message. The
# image is the Team Edition build, the compiled edition Mattermost, Inc.
# licenses under MIT; upstream's own compose file reaches for the enterprise
# build instead and runs it with no licence key. That image is distroless and
# runs as uid 2000, so the four directories it writes are made with that owner
# in step 2, while PostgreSQL 18 chowns its own cluster on first start.
# Upstream's /mattermost/logs mount is left out because `docker compose logs`
# already reads the console log, and an unrotated second copy on the same disk
# is a slow disk-full rather than a feature. Digests read 2026-08-06;
# PostgreSQL publishes amd64 and arm64, Mattermost only amd64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: mattermost-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: mattermost
      POSTGRES_USER: mmuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/mattermost/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mmuser -d mattermost"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 never leaves the compose network.

  mattermost:
    image: mattermost/mattermost-team-edition:11.9.0@sha256:1c538cf33c2144ba2c825571cd414aaaebf8d8c231d4b18081b811cd0ca0ef2a
    container_name: mattermost
    platform: linux/amd64
    restart: unless-stopped
    environment:
      MM_SQLSETTINGS_DRIVERNAME: postgres
      MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:${DB_PASSWORD}@postgres:5432/mattermost?sslmode=disable&connect_timeout=10"
      # Caddy terminates TLS, so this says https although the container
      # speaks plain http. Every invite link is built from it.
      MM_SERVICESETTINGS_SITEURL: https://${DOMAIN}
      # Anonymous signup stays shut; the server exempts the first account.
      MM_TEAMSETTINGS_ENABLEOPENSERVER: "false"
      # No SMTP and no telemetry: no mail, nothing reported to Sentry.
      MM_EMAILSETTINGS_SENDEMAILNOTIFICATIONS: "false"
      MM_LOGSETTINGS_ENABLEDIAGNOSTICS: "false"
    volumes:
      - /srv/mattermost/config:/mattermost/config
      - /srv/mattermost/data:/mattermost/data
      - /srv/mattermost/plugins:/mattermost/plugins
      - /srv/mattermost/client-plugins:/mattermost/client/plugins
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8113.
      - "127.0.0.1:8113:8065"
    depends_on:
      postgres:
        condition: service_healthy
Caddyfilethe hostname and TLS31 lines

authored from upstream docs, never pasted · 1,344 bytes

# Mattermost · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.mattermost.com/deployment-guide/server/deploy-containers.html 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. That hostname is
# also MM_SERVICESETTINGS_SITEURL in compose.yml, and Mattermost builds every
# invite link and websocket address from it, so the two have to agree.

<DOMAIN> {
	encode zstd gzip

	# This server holds a team's messages, so nothing here should be sniffed
	# or handed to another site in a referrer. X-Frame-Options is absent on
	# purpose: Mattermost sets SAMEORIGIN and its own Content-Security-Policy,
	# and one set here would overwrite the application's answer.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8113 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. The webapp holds a
	# websocket open at /api/v4/websocket for as long as it is on screen, and
	# Caddy's reverse_proxy performs that upgrade with no extra directive.
	reverse_proxy 127.0.0.1:8113
}
install.shthe same install, no agent157 lines

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

#!/usr/bin/env bash
# Mattermost · 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=chat.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.mattermost.com/deployment-guide/server/deploy-containers.html
#   https://github.com/mattermost/docker/blob/main/env.example
#   https://docs.mattermost.com/deployment-guide/software-hardware-requirements.html
#   https://github.com/mattermost/mattermost/blob/v11.9.0/server/build/Dockerfile
#
# One secret is generated here, on this machine: the PostgreSQL password. It
# goes into /srv/mattermost/.env with mode 600 and is never printed.
#
# This script cannot create the first Mattermost account, because only a human
# with a browser can. Until that account exists, anyone who reaches DOMAIN_HOST
# can create it and become the system administrator, so do it the minute this
# script finishes. The closing summary says so again.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/mattermost}"
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. chat.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"

arch="$(dpkg --print-architecture 2>/dev/null || uname -m)"
case "$arch" in
	amd64|x86_64) ;;
	*) die "this machine reports ${arch}; Mattermost publishes an amd64 image only" ;;
esac

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; Mattermost plus PostgreSQL wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 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 ----------------------------------------------------
#
# The server image is distroless and runs as uid 2000. PostgreSQL chowns its
# own cluster directory on first start, so that one is left to root.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 2000 -g 2000 "$APP_DIR/config" "$APP_DIR/data" "$APP_DIR/plugins" "$APP_DIR/client-plugins"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: this value is pasted into a connection string, where
# + and / would need escaping. Read it later with
#   sudo grep DB_PASSWORD /srv/mattermost/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		DOMAIN=${DOMAIN_HOST}
		DB_PASSWORD=$(openssl rand -hex 32)
	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-mattermost"
	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 neither 8113 nor 5432 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8113 and 5432 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 -------------------------------------------------------------
#
# Mattermost runs its own database migrations on the way up, so the first start
# is much slower than the second.

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/api/v4/system/ping" | grep -q '"status":"OK"' \
	|| die "ping answered 200 without status OK. Check: docker compose logs --tail 40 mattermost"

# The server should still be reporting that it has no accounts. If it does not,
# somebody already claimed the administrator account on this hostname.
curl -sS "https://${DOMAIN_HOST}/api/v4/config/client" | grep -q '"NoAccounts":"true"' \
	|| die "this server already has an account. If that was not you, stop and investigate before using it."

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

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

cat <<-DONE

	Mattermost is answering at https://${DOMAIN_HOST}/api/v4/system/ping

	  1. Open https://${DOMAIN_HOST} NOW and create your account. The screen
	     is headed "Create your account", and the first account made on this
	     server becomes the system administrator. Until you make it, anyone
	     who loads that page can. Put the account password in your password
	     manager: there is no mail here, so there is no reset link.
	  2. Then prove signups are shut. This has to answer 403:
	       curl -sS -o /dev/null -w '%{http_code}\n' -X POST \\
	         -H 'Content-Type: application/json' \\
	         -d '{"email":"signup-check@example.invalid","username":"signupcheck"}' \\
	         https://${DOMAIN_HOST}/api/v4/users
	  3. Invitations are copyable links from the Invite People dialog, because
	     this install has no SMTP. Mail is the thing to add next if you want
	     password resets to work.
	  4. First backup written to $APP_DIR/backups: a database dump and a file
	     archive. They predate your account, so take a second pair once you are
	     in. Both sit 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 Slack.

  • The licence is two licences, and the one you get depends on which build you run. Mattermost licenses its source under AGPL-3.0 or a commercial agreement, which is why GitHub reports no licence for the repository at all, and it licenses the versions it compiles itself under MIT. This install runs the Team Edition image, the compiled build whose licence is the plain one. Upstream's own compose file defaults to the enterprise image running with no licence key instead.
  • Team Edition is where the paid feature ladder starts. Unlimited users, channels, threads, file sharing, search and the official desktop and mobile apps are all here, and your message history has no ninety-day cliff because it is rows in your PostgreSQL. SAML and AD/LDAP sign-on, guest accounts, custom user groups, compliance export, data retention policies, legal hold, Elasticsearch and high-availability clustering are what Professional and Enterprise sell.
  • No mail means invitations are links you copy and paste. The chat works fine without SMTP, which is why this install skips it, but nobody gets an invitation email, nobody gets a notification digest, and a forgotten administrator password has no reset link at all. Put that password in a password manager on day one and add mail before the team grows past the people you can hand a link to.
  • Intel and AMD only. Mattermost publishes its image for amd64 alone, so the cheap ARM instance and the Raspberry Pi are out on the server path, and on a Mac with Apple Silicon it runs under x86 translation and starts slowly. This is the one constraint here that no amount of configuration works around.
  • Two things to back up and both matter. PostgreSQL holds every message and account, the data directory holds every file anyone uploaded, and the config directory holds the at-rest encryption key the server generated on its first boot. A database restored without the other two is a chat history with broken attachments in an install that does not recognise its own tokens.

Where this came from

“You are licensed to use compiled versions of the Mattermost platform produced by Mattermost, Inc. under an MIT LICENSE”

  • GitHub reports no single licence for the repository because Mattermost licenses its source under AGPL-3.0 or a commercial licence, while the compiled builds it publishes carry an MIT licence. source
  • Upstream's own compose file runs two services and chooses between the team-edition and enterprise-edition images with one variable, which defaults to the enterprise build. source
  • Mattermost supports 64-bit x86 processors and requires PostgreSQL 14 or later, with 1 vCPU and 2 GB of RAM as the floor for up to 1,000 users. source
  • The server image is distroless, runs as uid 2000, exposes 8065, and declares volumes for its config, data, logs and plugin directories. source
  • The very first account is exempt from the open-server check in the server's own code, so the administrator can be created on first visit while every later anonymous signup is refused. source

Questions people actually ask

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

  • Can I self-host Slack?

    Not Slack 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 Mattermost. Channels, threads and file sharing for a team, on a server you own, with no per-seat meter and no ninety-day history cliff. The install is one evening: 2 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 110 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 Slack?

    Mattermost. Channels, threads and file sharing for a team, on a server you own, with no per-seat meter and no ninety-day history cliff. The closest thing to a like-for-like swap. Channels, threads, mentions, file sharing and search behave the way a Slack team expects, the official desktop and mobile apps are real apps rather than a browser tab, and the message history that Slack's free plan deletes after ninety days is rows in a PostgreSQL you own. What you take on is a server with a certificate on it, and a licence ladder of its own: the free Team Edition stops short of SSO, guest accounts and compliance export. Mattermost is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Slack?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Mattermost itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Slack Pro, $87.50/mo — $1,050 a year, 10 seats assumed.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Mattermost install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Mattermost 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 Mattermost 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: A chat server only this computer can open is not a chat server for a team: nobody you invite can reach http://localhost:8113, and neither can the Mattermost apps on your own phone. 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.