Skip to content

Instantly share code, notes, and snippets.

@chripede
Last active April 2, 2025 06:36
Show Gist options
  • Save chripede/99b7eaa1101ee05cc64a59b46e4d299f to your computer and use it in GitHub Desktop.
Save chripede/99b7eaa1101ee05cc64a59b46e4d299f to your computer and use it in GitHub Desktop.
Self-hosted Stalwart email server

Self-hosted Stalwart mail server

Introduction

This guide will guide you through setting up Stalwart as your own email server. Optionally it can also help you hide it behind a Tailscale network and use Roundcube for webmail.

Before you get started

You will need your own domain name. This guide also assumes that you are installing on a VPS that has a static IP and a configurable firewall. The assumed OS is Ubuntu 24.04.

Stalwart doesn't use a lot of resources, so if you are just using your VPS for this, buy cheap. Hetzner Cloud is a good choice.

We will be using emaildomain.com as the domain. It's assumed that your emaildomain.com has an A record pointing to your VPS IP.

Basic setup

We will be installing everything in docker containers using docker compose. Lets install it

apt install docker.io docker-compose-v2

If you are not running as root you should add your user to the docker group to avoid sudo'ing every command: adduser USER docker

Create a directory for the docker compose file. This directory will also hold data and configuration files:mkdir -p stacks/stalwart. This is the directory we will be using from now.

Stalwart

Install

Create a compose.yml file with the following contents:

services:
  stalwart-mail:
    image: stalwartlabs/mail-server:latest
    volumes:
      - ./stalwart-mail:/opt/stalwart-mail
    restart: unless-stopped
    ports:
      - 443:443
      - 8080:8080
      - 25:25
      - 587:587
      - 465:465
      - 143:143
      - 993:993
      - 4190:4190
      - 110:110
      - 995:995

Make sure to open the ports in your firewall (if you are using one).

Start the service. For this first run we are not going to run it in the backround, as Stalwart will log the admin user password to the log docker compose up.

The output will look like this:

[+] Running 8/8
 ✔ stalwart-mail Pulled                                                                                                                                               8.5s 
   ✔ 3815f79548aa Pull complete                                                                                                                                       4.9s 
   ✔ d05ac034a050 Pull complete                                                                                                                                       5.1s 
   ✔ 69a176fe4a1f Pull complete                                                                                                                                       5.4s 
   ✔ ff59faa70d57 Pull complete                                                                                                                                       5.8s 
   ✔ 13eed8633459 Pull complete                                                                                                                                       6.0s 
   ✔ 599733dffedd Pull complete                                                                                                                                       6.0s 
   ✔ 6cc142849f3d Pull complete                                                                                                                                       6.1s 
[+] Running 2/2
 ✔ Network stalwart_default            Created                                                                                                                        0.2s 
 ✔ Container stalwart-stalwart-mail-1  Created                                                                                                                        0.1s 
Attaching to stalwart-mail-1
stalwart-mail-1  | ✅ Configuration file written to /opt/stalwart-mail/etc/config.toml
stalwart-mail-1  | 🔑 Your administrator account is 'admin' with password 'f0tAKRWSHJ'.

Open http://emaildomain.com:8080 and login with the user/password combo printed in the log.

Optional choices for storage

Note

If you are fine with using RocksDB for your server you can skip this section.

I already had a PostgreSQL server setup with working backup. For that reason I wanted to use that instead of RocksDB. I also opted for using S3 storage (Minio) for blobs (actual emails) because I can do streaming backup of that using Minios mc command.

Make sure you do this before you create domains, users etc. as that will be lost when you change the storage type.

PostgreSQL

If you don't already have a PostgreSQL server but still want to use it, you can add this to your compose file:

  db:
    image: postgres
    restart: unless-stopped
    shm_size: 128mb
    environment:
      POSTGRES_USER: stalwart
      POSTGRES_PASSWORD: example
      POSTGRES_DB: stalwart
    volumes:
      - ./postgres:/var/lib/postgresql/data

You will also want to add this to the existing stalwart container:

    depends_on:
      - db

In the Stalwart web admin go to Settings -> Storage -> Stores and click + Create store. Create a store using your PostgreSQL info:

image

Now you need to actually use it. Go to Storage -> Settings. Change the stores to your newly created store. Skip Blob store if you want to use Minio for that.

You also have to change the internal directory to use PostgreSQL. Open Authentication -> Directories and click edit on the internal directory. Set the storage backend to your PostgreSQL store.

image

Minio

If you don't have an existing Minio setup, add this to the compose file:

  minio:
    image: quay.io/minio/minio:latest
    restart: unless-stopped
    volumes:
      - ./minio:/data
    ports:
      - 9001:9001
    environment:
      - MINIO_ROOT_USER=admin
      - MINIO_ROOT_PASSWORD=examplepassword
    command: server /data --console-address ":9001"

You will also want to add this to the existing stalwart container:

    depends_on:
      - minio

Open Minio admin at http://emaildomain.com:9001 and login with the provided user and password.

Create a bucket and name it stalwart. Create an access key and note down the access key and the secret key.

In the Stalwart web admin go to Settings -> Storage -> Stores and click + Create store. Create a store using your Minio info:

image

Warning

The current version of Stalwart admin insists on you also specifying Profile and Security Token even though they are both optional. Put in deleteme in both. After saving edit stalwart-mail/etc/config.toml and delete the two lines with deleteme in them.

In Storage -> Settings change the blob store to the newly create minio store.

Restart

Restart Stalwart to initialize the new stores: docker compoes restart stalwart-mail

SMTP Relay

You will probably want to use a relay for your outgoing emails, otherwise you need to deal with deliverability issues and/or blocked outgoing port 25. I recommend using SMTP2Go.com. They offer 1,000 monthly emails. Whichever provider you chose, make sure you configure your DNS they way they specify.

In Stalwart admin go to Settings -> SMTP -> Outbound -> Relay Hosts and click + Create host. Fill in your relay host info:

image

Configure Stalwart to use the relay for external mails. Go to Settings -> SMTP -> Outbound -> Routing. Add smtp2go as the next hop:

image

When using relays you have to disable DANE and MTA-STS. In Settings -> SMTP -> Outbound -> TLS change them to disabled:

image

Main domain config

Open Settings -> Server -> Network and put mail.emaildomain.com into the Hostname field.

TLS certs

To avoid self signed certificates when connecting to secure endpoints, you need to add an ACME provider to get SSL certificates. This guide is using Letsencrypt and Cloudflare DNS.

Go to Settings -> Server -> TLS -> ACME Providers and click + Create ACME provider.

Fill it out like so:

image

Set the DNS settings to Cloudflare and enter your API key. If you are not using Cloudflare you might want to select another challenge type.

Restart Stalwart to reload the new settings: docker compose restart stalwart-mail.

Adding your domain

We are now ready to add the domain to Stalwart. In Stalwart go to Management -> Directory -> Domains and click + Create domain. Put emaildomain.com in the Domain name field. Back at the Domains list click the three dots on emaildomain.com and select View DNS records.

Add those DNS records to your domain. If you are using Cloudflare you can save contents of Zonefile to a file and import it. If you already have an SPF field from your relay hosts, make sure you merge it into the one provided from Stalwart.

Adding your first user

Open Management -> Directory -> Accounts and click + Create account.

  • Login name is the username used to login. It can either be user or [email protected]. That is up to you. You can always change it.
  • Name is Firstname Lastname
  • Email is the actual email, [email protected] In Authentication make sure you put a password. Save changes.

Tip

If you want a catch-all address add an alias as @emaildomain.com.

Test

It should now work. Connect to IMAP using your favorite client. The DNS SRV records should give your client all the info it needs about ports and domains. If not, use mail.emaildomain.com as the IMAP/SMTP host.

Caddy

You could skip this step, but adding a reverse proxy in front of Stalwart makes it a lot easier to add more services without using non-standard port numbers.

Add this to your compose file:

  caddy:
    restart: unless-stopped
    image: ghcr.io/hotio/caddy:latest
    volumes:
      - ./caddy:/config

And reload docker compose: docker compose up -d. This will pull Caddy and create caddy/Caddyfile. Open that file and change it to something like this:

{
        http_port 80
        https_port 443
        acme_dns cloudflare your-cloudflare-api-key
}

mail.emaildomain.com {
        reverse_proxy stalwart-mail:8080
}

webmail.emaildomain.com {
        reverse_proxy roundcube:80
}

From your compose file, remove ports 443 and 8080 from stalwart-mail.

Restart Caddy: docker compose restart caddy. If you're using a firewall (you should) make sure you open port 80 and 443. Also close port 8080.

The admin should now show up on https://mail.emaildomain.com

Roundcube

Add this to the compose file and docker compose up -d

  roundcube:
    image: roundcube/roundcubemail:latest
    restart: unless-stopped
    volumes:
      - ./roundcube/db:/var/roundcube/db
      - ./roundcube/config/:/var/roundcube/config/
    environment:
      - ROUNDCUBEMAIL_DEFAULT_HOST=ssl://mail.emaildomain.com
      - ROUNDCUBEMAIL_DEFAULT_PORT=993
      - ROUNDCUBEMAIL_SMTP_SERVER=ssl://mail.emaildomain.com
      - ROUNDCUBEMAIL_SMTP_PORT=465
      - ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=50M

In your DNS settings you need to add an entry for webmail.emaildomain.com. I suggest a CNAME pointing to mail.emaildomain.com. Roundcube will now be at https://webmail.emaildomain.com

Tailscale

This part is entirely optional. I am running Tailscale on all my devices, at least the ones where I read emails, so I opted to hide everything but the incoming port 25 behind Tailscale.

To make this work we will need a Tailscale sidecar and a way to forward port 25 from the host network into Tailscale. We will be using HAProxy for that. We also need to change a few DNS records.

First add this to the compose file:

  tailscale-sidecar:
    image: tailscale/tailscale:latest
    hostname: stalwart
    environment:
      - TS_AUTHKEY=tskey-auth-key-from-your-account
      - TS_STATE_DIR=/var/lib/tailscale
      - TS_USERSPACE=false
    volumes:
      - /dev/net/tun:/dev/net/tun
      - ./tailscale:/var/lib/tailscale
    cap_add:
      - net_admin
      - sys_module
    restart: unless-stopped

  haproxy:
    image: haproxy:alpine
    ports:
      - 25:2525
    volumes:
      - ./haproxy/:/usr/local/etc/haproxy/
    restart: unless-stopped

We will also need a few changes to existing records in the compose file.

For stalwart-mail add:

    network_mode: service:tailscale-sidecar
    depends_on:
      - tailscale-sidecar

and remove all ports entries as well.

Open haproxy/haproxy.cfg and insert this:

global
    daemon
    maxconn 256
    log stdout  format raw  local0  info

defaults
    log global
    mode tcp
    option tcplog
    option dontlognull
    timeout connect 5000ms
    timeout client 50000ms
    timeout server 50000ms

frontend smtp-in
    bind *:2525
    mode tcp
    option tcplog
    default_backend bk_smtp

backend bk_smtp
    mode tcp
    server stalwart_smtp stalwart:25 send-proxy-v2

It's using stalwart:25 because the stalwart-mail container isn't directly connectable anymore, as it is using the sidecar network.

Start everything up docker compose up -d.

Because we are now proxying from HAProxy to Stalwart, we need to add the docker network IP range to Stalwart: Settings -> Server -> Network -> Proxy networks. To find the network IP range you can run docker network inspect stalwart_default or just use 172.0.0.0/8. Save and reload.

You can now use the hostname stalwart to connect to your IMAP server. That's not optimal as the DNS SRV records doesn't specify that and we also don't have a certificate. To fix this we can edit the DNS configuration at Cloudflare.

  • Change the A record for mail.emaildomain.com from the external IP to the Tailscale IP for the VPS.
  • Add a new A record smtp-in.emaildomain.com with the external IP
  • Change the MX record from mail.emaildomain.com to smtp-in.emaildomain.com

With these changes incoming mail will now go to smtp-in.emaildomain.com while everything else uses the Tailscale IP.

Tip

Set this machine to non-expiry in Tailscale admin.

Tip

You could also add Minio and PostgreSQL to the tailscale-sidecar network, but you will then need to update the hostname to stalwart in the storage configuration.

Warning

If you don't add Minio to the Tailscale network, at least remove the ports configuration for it or firewall port 9001.

@ilgigante77
Copy link

just to suggest to change the store also in authentication directories items. for me it didn't work until I changed to postgres in the internal directory.

@chripede
Copy link
Author

chripede commented Mar 4, 2025

just to suggest to change the store also in authentication directories items. for me it didn't work until I changed to postgres in the internal directory.

Yes, you are absolutely correct. I've updated the guide. Thanks!

@kylepyke
Copy link

This is awesome, thanks! Any thoughts on how to setup access using a tunnel service like Pangolin?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment