How to Deploy Mattermost on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Self-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.
- 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
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.
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:
DOMAINis the one value the official docs say you must change. It feedsMM_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_PASSWORDreplaces the shippedmmuser_passwordwith 48 random hex characters. Hex is deliberate: the password is interpolated into apostgres://connection string, and characters like@,/or#would break it.MATTERMOST_IMAGE_TAG=11.7.0pins the versionenv.exampleships with, so a latergit pullcan never upgrade you by accident. Mattermost's own docs recommend exact version tags over rolling ones for production.APP_PORT=127.0.0.1:8065is the port-hardening step. The without-nginx override publishes${APP_PORT}:8065, so givingAPP_PORTan address makes the mapping127.0.0.1:8065:8065— reachable by Caddy on the same host, not by the internet. No compose file is edited.COMPOSE_FILEis Docker Compose's own variable: it tells everydocker composecommand 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
.envwin. AnyMM_*variable passed to the container overridesconfig.json, and the System Console shows it greyed out. If a setting refuses to change in the UI, look in.envanddocker-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/udpand8443/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.examplenotes thatPOSTGRES_USERis created as a Postgres superuser; the repo includesdocs/creation-of-nonsuperuser.mdif 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.