Skip to content

How to Deploy Mattermost on a VPS

Updated Sep 2026

verified on Ubuntu 26.04 · Sep 2026
We earn commissions when you shop through the links below. Full disclosure →

Self-host Mattermost, the Slack-style team chat, on your own VPS using the official mattermost/docker repo — Postgres, a generated database password, a loopback-only app port, and Caddy for HTTPS.

Before you start
  • A VPS with at least 2 vCPU / 2 GB RAM (4 GB if the team is more than a handful of people)
  • A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
  • A domain you can point at the server, e.g. chat.example.com
  • Docker Engine + Compose installed (see the base guide below)
  • git on the server — the official deployment is a repository you clone
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

What Mattermost is

Mattermost is a Slack-style team chat you run yourself: channels, threads, direct messages, file sharing, webhooks and bot integrations, plus desktop and mobile apps that connect to your server instead of a vendor's. The server is written in Go with a React web client, stores everything in PostgreSQL, and the source is AGPL-3.0. We rate it 3 / 5 to deploy — not because any single step is hard, but because there are more moving parts than a one-container app: a database, a data directory with a fixed owner, a site URL that has to be right, and a port you need to keep off the public internet.

Mattermost maintains an official Docker deployment in the mattermost/docker repository, and this guide follows it rather than inventing a compose file of our own. That matters for upgrades: when Mattermost changes its recommended Postgres version or compose layout, you git pull and get the change, instead of diffing your file against theirs.

If you are still choosing a chat server, Mattermost vs Rocket.Chat covers the main alternative; Zulip and Synapse (Matrix) are the other self-hosted options on this site.

Server sizing

Our catalog lists 2 GB RAM as the minimum for Mattermost, and that is the number to plan around. On our test box (a 2 vCPU / 8 GB GCP instance on Ubuntu 26.04) the stack measured about 256 MB of RAM at idle with no users and about 2.2 GB of disk for the images and a fresh install. Idle is the floor, not the working figure: every connected client holds a WebSocket open, search indexing and plugins use memory, and Postgres grows its cache as the message history grows.

  • 2 GB RAM / 2 vCPU — a small team, a few dozen people, default plugins.
  • 4 GB RAM — the comfortable size once people actually use it all day, or if you enable Calls.
  • Disk — start with 40 GB. Uploaded files live on local disk under volumes/app/mattermost/data, and in an active workspace they, not the messages, are what fills it.

The upstream compose file sets memory limits of 4 GB for the app and 16 GB for Postgres. Those are ceilings, not reservations — the containers start fine on a smaller box.

Prepare the server

This guide assumes Docker Engine and the Compose plugin are installed. If not, work through Docker & Compose on Ubuntu first. You also need git:

sudo apt-get update
sudo apt-get install -y git openssl curl

Open SSH and the reverse-proxy ports. Mattermost's own port (8065) stays closed — Caddy is the only public door:

sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw --force enable
sudo ufw status verbose

ufw alone does not protect a Docker port. Docker writes its own iptables rules for published ports, and traffic to them is accepted before ufw's rules are consulted. A container published on 0.0.0.0:8065 is reachable from the internet even with ufw denying 8065. That is why the install below binds the app port to loopback instead of relying on the firewall.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

Paid link — we earn a commission if you shop through it.

Install Mattermost

Clone the official repository

[ -d ~/mattermost/.git ] || git clone https://github.com/mattermost/docker ~/mattermost
cd ~/mattermost
ls docker-compose.yml docker-compose.without-nginx.yml env.example

The repository ships a base docker-compose.yml (Postgres + Mattermost, no published ports) and two override files. docker-compose.nginx.yml adds an nginx container that terminates TLS itself; docker-compose.without-nginx.yml publishes Mattermost on port 8065 so an existing reverse proxy can front it. We use the second one, with Caddy in front.

Create .env with a generated database password

Everything is configured through .env, copied from env.example. The block below only creates it once, so re-running it never rotates a password the database has already been initialised with:

cd ~/mattermost
if [ ! -f .env ]; then
  cp env.example .env
  sed -i "s|^DOMAIN=.*|DOMAIN=chat.example.com|" .env
  sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" .env
  sed -i "s|^MATTERMOST_IMAGE_TAG=.*|MATTERMOST_IMAGE_TAG=11.7.0|" .env
  sed -i "s|^APP_PORT=.*|APP_PORT=127.0.0.1:8065|" .env
  echo "COMPOSE_FILE=docker-compose.yml:docker-compose.without-nginx.yml" >> .env
fi
chmod 600 .env
grep -E '^(DOMAIN|MATTERMOST_IMAGE|MATTERMOST_IMAGE_TAG|APP_PORT|COMPOSE_FILE|POSTGRES_USER|POSTGRES_DB)=' .env

Replace chat.example.com with your own hostname before you run it (or edit .env afterwards and recreate the containers). What each line does:

  • DOMAIN is the one value the official docs say you must change. It feeds MM_SERVICESETTINGS_SITEURL=https://${DOMAIN}, which Mattermost uses for every link it generates — email notifications, OAuth callbacks, the mobile app. A wrong Site URL is the most common cause of "it loads but half of it is broken".
  • POSTGRES_PASSWORD replaces the shipped mmuser_password with 48 random hex characters. Hex is deliberate: the password is interpolated into a postgres:// connection string, and characters like @, / or # would break it.
  • MATTERMOST_IMAGE_TAG=11.7.0 pins the version env.example ships with, so a later git pull can never upgrade you by accident. Mattermost's own docs recommend exact version tags over rolling ones for production.
  • APP_PORT=127.0.0.1:8065 is the port-hardening step. The without-nginx override publishes ${APP_PORT}:8065, so giving APP_PORT an address makes the mapping 127.0.0.1:8065:8065 — reachable by Caddy on the same host, not by the internet. No compose file is edited.
  • COMPOSE_FILE is Docker Compose's own variable: it tells every docker compose command in this directory to load both files, so you never forget the -f ... -f ... pair and start the stack without its port.

MATTERMOST_IMAGE stays mattermost-enterprise-edition, the upstream default. Without a licence it runs as Entry, a free mode under a commercial licence that keeps only the latest 10,000 channel messages visible across all channels. Set it to mattermost-team-edition before the first start if you want the MIT-licensed build with no history cap (upstream intends it for teams under 250 users, and it has no SSO). The rest of this guide works the same with either image. Mattermost vs Zulip covers the editions in detail.

Create the data directories

Mattermost runs as UID/GID 2000 inside the container, and the bind-mounted directories must be owned by that user or the server cannot write its config:

cd ~/mattermost
mkdir -p ./volumes/app/mattermost/{config,data,logs,plugins,client/plugins,bleve-indexes}
sudo chown -R 2000:2000 ./volumes/app/mattermost

Start it

cd ~/mattermost
docker compose up -d
for i in $(seq 1 60); do
  curl -fsS http://127.0.0.1:8065/api/v4/system/ping && break
  sleep 5
done
echo
docker compose ps

The first start pulls both images and runs the database migrations, which takes a minute or two. The loop waits for /api/v4/system/ping to answer with a JSON status. Confirm the port really is loopback-only:

sudo ss -tlnp | grep 8065

You want 127.0.0.1:8065, not 0.0.0.0:8065.

HTTPS + domain

Point an A record for chat.example.com at the server's public IP, then put Caddy in front of the loopback port. Automatic HTTPS with Caddy covers installing it; the site block is:

chat.example.com {
    reverse_proxy 127.0.0.1:8065
}

Caddy passes WebSocket upgrades through by default, which Mattermost needs for live message delivery — no extra headers required. If you run Caddy as a container rather than on the host, 127.0.0.1 is the container's own loopback; attach Caddy to the Mattermost compose network and proxy to mattermost:8065 instead.

Once Caddy has a certificate, open https://chat.example.com. The first account you create becomes the System Admin — do it now, before anyone else finds the page.

Securing it

  • Registration is the next door to close. After creating your admin account and team, go to System Console → Authentication → Signup and decide how people get in: invite links, an allowed email domain, or SSO (a self-hosted IdP like Keycloak works well). Don't leave open signup on a public hostname.
  • Settings in .env win. Any MM_* variable passed to the container overrides config.json, and the System Console shows it greyed out. If a setting refuses to change in the UI, look in .env and docker-compose.yml.
  • Calls needs its own port. The override also publishes CALLS_PORT (8443, UDP and TCP) on all interfaces for the Calls plugin's media traffic. If you use Calls, open it (sudo ufw allow 8443/udp and 8443/tcp) and read Mattermost's Calls deployment docs. If you don't, disable the Calls plugin in the System Console; remember that ufw does not filter Docker's published ports, so blocking it needs your provider's network firewall.
  • The database is a superuser. env.example notes that POSTGRES_USER is created as a Postgres superuser; the repo includes docs/creation-of-nonsuperuser.md if you want Mattermost to connect with a less-privileged role.

Backups

Two things hold all your state: the Postgres database (messages, users, channels) and volumes/app/mattermost (uploaded files, config.json, plugins). Back up both, together:

cd ~/mattermost
mkdir -p ~/mattermost-backups
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \
  | gzip > ~/mattermost-backups/mattermost-db-$(date +%F).sql.gz
sudo tar czf ~/mattermost-backups/mattermost-files-$(date +%F).tar.gz \
  -C ~/mattermost volumes/app/mattermost .env
ls -lh ~/mattermost-backups

pg_dump takes a consistent snapshot while the server keeps running. Include .env — without the Postgres password and pinned tag, a restore is guesswork — and keep the archives somewhere encrypted, because .env holds a secret. Copy them off the box; a backup on the same disk is not a backup.

To restore onto a fresh install, bring up only Postgres, load the dump, then unpack the files and start the app:

cd ~/mattermost
docker compose up -d postgres
gunzip -c ~/mattermost-backups/mattermost-db-YYYY-MM-DD.sql.gz \
  | docker compose exec -T postgres sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"'
sudo tar xzf ~/mattermost-backups/mattermost-files-YYYY-MM-DD.tar.gz -C ~/mattermost
docker compose up -d

Upgrades

Upgrades follow the upstream procedure: pull the repo for any compose or env.example changes, move the pinned tag, redeploy. Take a backup first.

cd ~/mattermost
git pull
diff <(grep -oE '^[A-Z_]+=' env.example | sort) <(grep -oE '^[A-Z_]+=' .env | sort) || true
docker compose pull
docker compose up -d
docker compose ps

The diff lists variables that exist in the new env.example but not in your .env (and vice versa) — add any new ones before recreating. To move to a new release, change MATTERMOST_IMAGE_TAG in .env to the exact version you want, then run the last three lines again. Read the release notes before a major version jump. Don't bump POSTGRES_IMAGE_TAG across a Postgres major version on an existing data directory: a new major cannot read the old one's files, and moving requires a dump and restore.

Troubleshooting

The app container restarts in a loop and the logs mention permission denied. The volume directories aren't owned by UID 2000. Re-run the chown -R 2000:2000 ./volumes/app/mattermost step and docker compose up -d.

It can't connect to the database after you changed the password. The Postgres image only reads POSTGRES_PASSWORD when it initialises an empty data directory. Changing .env afterwards changes what Mattermost sends, not what Postgres expects. Change it inside Postgres too (ALTER USER), or put the old value back.

curl 127.0.0.1:8065 is refused. Check docker compose ps — if the app container has no port listed, the stack was started without the override. Make sure COMPOSE_FILE is in .env, then docker compose up -d.

Links in emails point at the wrong host, or the mobile app won't connect. The Site URL is wrong. Fix DOMAIN in .env and recreate with docker compose up -d; the System Console can't change it while the environment variable is set.

Messages only appear after a refresh. The WebSocket isn't getting through. With the Caddy block above this works out of the box; with another proxy, check that it forwards Upgrade/Connection headers.

To read the logs while debugging:

cd ~/mattermost
docker compose logs -f mattermost

Verification + next steps

You're done when you can load https://chat.example.com over a valid certificate, sign in as the System Admin, post a message from the browser and see it arrive instantly in the desktop or mobile app, confirm with ss that 8065 listens only on 127.0.0.1, and find today's database dump and file archive on a machine other than this one.

From there: connect SSO so accounts follow your directory, set up SMTP in the System Console so notifications and password resets actually send, and point an uptime monitor such as Uptime Kuma at /api/v4/system/ping from a different box. For host picks, see Best VPS for Self-Hosting.

Next steps

How to self-host Mattermost →More self-hosted team chat tools →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.