Mattermost self-hosting tutorial

Fabio Natali, 8 February 2025

Intro

In a recent post I've talked about Nextcloud, a Free and Open Source file-hosting solution. Today I'll describe how to self-host Mattermost, an Open Source instant messaging system, similar to commercial services such as Slack and Microsoft Teams.

I'll be hosting Mattermost on a DigitalOcean server, although things shouldn't be much different with other cloud providers or if you want to use a local machine instead.

This might not be relevant for you, but if you are migrating from another Mattermost instance, I'll also explain how to transfer the old data over.

Cloud server provisioning

Let's start by spinning up a cloud server with some pre-installed packages and some basic initial configuration.

DigitalOcean account

You'll need a DigitalOcean account if you want to create an actual Mattermost instance as you follow this guide. Note that you'll be charged for creating DigitalOcean machines (or droplets, in DigitalOcean jargon) and using other of their services. Spinning up a new DigitalOcean machine is very easy, make sure you cancel any unused instances to avoid extra costs.

Most DigitalOcean operations can be launched via a command-line tool called doctl. This guide assumes doctl is installed and that the DIGITALOCEAN_ACCESS_TOKEN environment variable is set to a valid DigitalOcean API token.

export DIGITALOCEAN_ACCESS_TOKEN=`pass show digitalocean.com/token`

In order to provision a new machine, first define the SSH key that you intend to use to connect to it. The list of the SSH keys available for a DigitalOcean account can be retrieved with the following command.

doctl compute ssh-key list

You also need to specify a system image. The list of available images can be retrieved with doctl compute image list or found at this page. Use a recent Ubuntu, e.g. ubuntu-24-04-x64.

Finally, you'll need a domain name to assign to Mattermost once installed. Choose a domain and make sure you have access to its DNS settings.

Machine pre-configuration with cloud-init

We'll be using cloud-init to apply some initial configuration to the newly created machine, like installing some packages and setting up a firewall. Prepare a cloud-init configuration file like the one below, you'll need it when provisioning the machine (see the --user-data-file option). Note that the file has to start with #cloud-config to be interpreted properly.

#cloud-config
package_update: true
package_upgrade: true
package_reboot_if_required: true
packages:
  - certbot
  - mattermost
  - nginx
  - postgresql
  - python3-certbot-nginx
apt:
  sources:
    mattermost.list:
      source: "deb https://deb.packages.mattermost.com $RELEASE main"
      keyid: A1B3 1D46 F0F3 A10B 02CF  2D44 F8F2 C317 4477 4B28
      keyserver: hkp://keyserver.ubuntu.com:80
write_files:
  - path: /etc/nftables.conf
    content: |
      flush ruleset
      table inet firewall {
          chain inbound {
              type filter hook input priority 0; policy drop;
              iif lo accept
              meta l4proto {icmp, ipv6-icmp} accept
              ct state vmap {
                  established: accept, related: accept, invalid: drop
              }
              ct state new limit rate over 1/second burst 10 packets drop
              tcp dport 80 accept
              tcp dport 443 accept
              tcp dport ssh accept
          }
          chain forward {
              type filter hook forward priority 0; policy drop;
          }
      }
runcmd:
  - [ systemctl, enable, nftables.service ]
  - [ systemctl, restart, nftables.service ]

Provisioning

A new DigitalOcean machine can now be provisioned with this command.

export region=lon1
export size=s-1vcpu-2gb
export image_id=ubuntu-24-04-x64
export ssh_key_id=00000000
doctl compute droplet create \
    --enable-ipv6 \
    --image "${image_id}" \
    --region "${region}" \
    --size "${size}" \
    --ssh-keys "${ssh_key_id}" \
    --user-data-file build/cloud-config \
    --wait \
    mattermost

After a short while, the command should return a success message that includes some machine details, such as its IPv4 and IPv6 addresses.

Connect to the machine from the DigitalOcean web console and retrieve its SSH public key fingerprint with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.

From a terminal, SSH into the machine as root, check that the SSH fingerprint corresponds to what obtained from the web console.

Once logged in, verify that the firewall has been activated correctly with nft list ruleset. The output should be similar to what was defined in our cloud-config file.

Initial setup

If you have used the cloud-init configuration as suggested at the beginning of this guide, Mattermost, PostgreSQL, and Nginx should be already installed on your server. We will now need to configure each of these services, as well as create a PostgreSQL database and request a TLS certificate from Let's Encrypt.

PostgreSQL

SSH into the server as root. Connect to PostgreSQL with sudo --user=postgres psql, then run these commands:

CREATE DATABASE mattermost;
\connect mattermost;
CREATE USER mmuser WITH PASSWORD 'PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE mattermost to mmuser;
ALTER DATABASE mattermost OWNER TO mmuser;
GRANT USAGE, CREATE ON SCHEMA PUBLIC TO mmuser;

Do not forget to replace PASSWORD with a strong password. Edit your Postgres' pg_hba.conf to allow local connections. In practice, this means replacing:

local   all             all                        peer
host    all             all         127.0.0.1/32   scram-sha-256
host    all             all         ::1/128        scram-sha-256

With:

local   all             all                        trust
host    all             all         127.0.0.1/32   trust
host    all             all         ::1/128        trust

Reload Postgres with systemctl reload postgresql.

It should be now possible to connect to the database with the credentials used above:

psql --dbname=mattermost --username=mmuser --password

For further details, look at the Mattermost documentation.

Mattermost

If started automatically already, stop the Mattermost service with systemctl stop mattermost. Save the following configuration in /opt/mattermost/config/config.json, then restart the service.

{
    "ServiceSettings": {
        "SiteURL": "https://mattermost.example",
        "EnableMultifactorAuthentication": true,
        "EnforceMultifactorAuthentication": true,
        "EnableEmailInvitations": true,
        "EnableCustomBrand": true,
        "CustomBrandText": "Acme Corp"
    },
    "SqlSettings": {
        "DriverName": "postgres",
        "DataSource": "postgres://mmuser:PASSWORD@localhost:5432/mattermost"
    },
    "EmailSettings": {
        "SendEmailNotifications": true,
        "RequireEmailVerification": true,
        "FeedbackName": "No-Reply",
        "FeedbackEmail": "mattermost@example.com",
        "ReplyToAddress": "info@example.com",
        "EnableSMTPAuth": true,
        "SMTPUsername": "mattermost@example.com",
        "SMTPPassword": "PASSWORD",
        "SMTPServer": "mail.example.com",
        "SMTPPort": "465",
        "ConnectionSecurity": "TLS",
        "SendPushNotifications": true,
        "PushNotificationServer": "https://push-test.mattermost.com/"
    }
}

It might be necessary to adjust the file's permissions and ownership:

chmod 600 config.json
chown mattermost:mattermost config.json

Nginx

First, choose a domain name, e.g. mattermost.example, and configure its DNS to point to the server.

Overwrite the Nginx default configuration /etc/nginx/sites-enabled/default with:

server {
    listen 80 default_server;
    server_name mattermost.example;
}

Use certbot to get a TLS certificate from Let's Encrypt:

certbot \
    --agree-tos \
    --domain mattermost.example \
    --email info@example.com \
    --non-interactive \
    --nginx

Now overwrite the Nginx configuration again with:

upstream backend {
    server localhost:8065;
    keepalive 32;
}

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name mattermost.example;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name mattermost.example;

    ssl_certificate /etc/letsencrypt/live/mattermost.example/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mattermost.example/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    add_header Strict-Transport-Security max-age=15768000;
    ssl_stapling on;
    ssl_stapling_verify on;

    location ~ /api/v[0-9]+/(users/)?websocket$ {
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        client_max_body_size 50M;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Frame-Options SAMEORIGIN;
        proxy_buffers 256 16k;
        proxy_buffer_size 16k;
        client_body_timeout 60s;
        send_timeout 300s;
        lingering_timeout 5s;
        proxy_connect_timeout 90s;
        proxy_send_timeout 300s;
        proxy_read_timeout 90s;
        proxy_http_version 1.1;
        proxy_pass http://backend;
    }

    location / {
        client_max_body_size 100M;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Frame-Options SAMEORIGIN;
        proxy_buffers 256 16k;
        proxy_buffer_size 16k;
        proxy_read_timeout 600s;
        proxy_http_version 1.1;
        proxy_pass http://backend;
    }
}

Restart Nginx with systemctl restart nginx. If this doesn't work, you might need to kill Nginx with pkill -f nginx & wait $!, then start it with systemctl start nginx. You should now be able to connect to https://mattermost.example.

Certbot should have created a systemd timer that takes care of automatic certificate renewals. Run systemctl list-timers to confirm. The renewal process can be tested with certbot renew --dry-run.

Data migration

If you're migrating from another Mattermost instance, this is the time to transfer all your data over, i.e. users and chat history. We assume that the old instance was based on the same Mattermost version and on the same database, PostgreSQL.

You will need:

  • a dump of the old PostgreSQL database,
  • a copy of the old Mattermost data folder,
  • a copy of the old Mattermost configuration file config.json.

Stop Mattermost with systemctl stop mattermost. Load the old database with:

psql \
    --dbname=mattermost \
    --username=mmuser \
    --password < dump.sql

Depending on your circumstances, you might want to consider the following extra options: --no-psqlrc, --set ON_ERROR_STOP=on, and --single-transaction.

If you're migrating from an older PostgreSQL version, connect to the database with sudo --user=postgres psql and run this command:

\connect mattermost;
ANALYZE VERBOSE;

This is important for performance reasons. See the Mattermost docs for details.

Copy the old data folder over to /opt/mattermost/data/. Make sure the files belong to user mattermost and group mattermost.

Compare the old config.json with the new one and see if there's anything that needs to be adjusted. Finally, restart the service with systemctl start mattermost.

Backups

You most certainly want to have a backup mechanism in place for disaster recovery. You will need to backup:

  • the PostgreSQL database,
  • the Mattermost data folder,
  • the Mattermost configuration (config.json),
  • the Nginx configuration and TLS certificates.

You might also want to keep note of the exact Mattermost, Nginx, and PostgreSQL versions used.

Most of the above can be taken care of by simply enabling DigitalOcean's instance backup mechanism. This will increase your bill, but it's probably a good investment.

For the database backup, however, you'll need to use something specific like pg_dump. Without pg_dump (or a similar tool) there's a risk to end up with inconsistent snapshots, e.g. if they get taken while a database transaction is in progress.

Put this, or a variation thereof, in a cron script or in a systemd timer. The dumped file will be then backed up as part of the DigitalOcean instance backup mechanism. Optionally, you can add a call to a health check service so as to get a notification if the backup script stops working.

#!/bin/bash

# A dummy script to take a backup copy of Mattermost's database.

set -euo pipefail
sudo --user=postgres pg_dump --dbname=mattermost > /root/pg_dump.sql
curl --max-time 10 --retry 5 https://healthcheck.example.com/healthcheck-id

Todo

At this point it'd be good to set up a fail2ban service to detect and block malicious login attempts. This is left as an exercise for the reader.

Depending on your context, in addition or as an alternative to fail2ban, you should consider putting the whole Mattermost service behind a virtual private network. If you're so inclined, WireGuard is an excellent tool.

Outro

As always, this post is mostly a note-to-self - although I do hope someone else might find it useful. Should you find anything wrong or incomplete and you want to let me know, that'd be very appreciated. Until next!

Revision 760ac98.