Skip to content

Instantly share code, notes, and snippets.

@simhaonline
Forked from chripede/stalwart-self-host.md
Created April 20, 2025 08:02
Show Gist options
  • Save simhaonline/d7f877a36de79932dc2b4c9d2f71bb9f to your computer and use it in GitHub Desktop.
Save simhaonline/d7f877a36de79932dc2b4c9d2f71bb9f to your computer and use it in GitHub Desktop.

Revisions

  1. @chripede chripede revised this gist Mar 4, 2025. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions stalwart-self-host.md
    Original file line number Diff line number Diff line change
    @@ -123,6 +123,8 @@ In the Stalwart web admin go to `Settings -> Storage -> Stores` and click `+ Cre

    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](https://gist.github.com/user-attachments/assets/2f8c028a-e7d7-4c8a-9b6a-96ef17232eb3)

    ##### Minio
  2. @chripede chripede revised this gist Feb 25, 2025. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions stalwart-self-host.md
    Original file line number Diff line number Diff line change
    @@ -341,6 +341,8 @@ It's using `stalwart:25` because the `stalwart-mail` container isn't directly co

    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.
  3. @chripede chripede created this gist Feb 24, 2025.
    359 changes: 359 additions & 0 deletions stalwart-self-host.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,359 @@
    # Self-hosted Stalwart mail server

    - [Self-hosted Stalwart mail server](#self-hosted-stalwart-mail-server)
    * [Introduction](#introduction)
    * [Before you get started](#before-you-get-started)
    * [Basic setup](#basic-setup)
    * [Stalwart](#stalwart)
    + [Install](#install)
    - [Optional choices for storage](#optional-choices-for-storage)
    * [PostgreSQL](#postgresql)
    * [Minio](#minio)
    * [Restart](#restart)
    + [SMTP Relay](#smtp-relay)
    + [Main domain config](#main-domain-config)
    + [TLS certs](#tls-certs)
    + [Adding your domain](#adding-your-domain)
    + [Adding your first user](#adding-your-first-user)
    + [Test](#test)
    * [Caddy](#caddy)
    * [Roundcube](#roundcube)
    * [Tailscale](#tailscale)


    ## 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](https://gist.github.com/user-attachments/assets/8b690619-6522-4d57-845f-f85aecd6ed4c)

    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.

    ![image](https://gist.github.com/user-attachments/assets/2f8c028a-e7d7-4c8a-9b6a-96ef17232eb3)

    ##### 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](https://gist.github.com/user-attachments/assets/367c18a9-ba56-4f4e-9a0f-db4d35189ec3)

    > [!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](https://gist.github.com/user-attachments/assets/3b1ed325-ee21-4934-88ff-808214849830)

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

    ![image](https://gist.github.com/user-attachments/assets/d66ab219-2d22-4ef6-960c-c3d3d5efda61)

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

    ![image](https://gist.github.com/user-attachments/assets/6924ef11-99d9-4f13-9284-4f4626c2fc16)

    ### 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](https://gist.github.com/user-attachments/assets/b389248b-fa7a-47f7-aebf-db750ae93f70)

    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.

    * Send an email to [email protected] from https://sendtestemail.com/
    * Send an email from [email protected] to https://www.mail-tester.com/
    * Check your DNS etc from mxtoolbox.com

    ## 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`.

    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.