Skip to content

# Mailserver behind Proxy

## Using a Reverse Proxy

Guidance is provided via a Traefik config example, however if you're only familiar with configuring a reverse proxy for web services there are some differences to keep in mind.

- A security concern where preserving the client IP is important but needs to be handled at Layer 4 (TCP).
- TLS will be handled differently due protocols like STARTTLS and the need to comply with standards for interoperability with other MTAs.
- The ability to route the same port to different containers by FQDN can be limited.

This reduces many of the benefits for why you might use a reverse proxy, but they can still be useful.

Some deployments may require a service to route traffic (kubernetes) when deploying, in which case the below advice is important to understand well.

The guide here has also been adapted for our Kubernetes docs.

## What can go wrong?

Without a reverse proxy involved, a service is typically aware of the client IP for a connection.

However when a reverse proxy routes the connection this information can be lost, and the proxied service mistakenly treats the client IP as the reverse proxy handling the connection.

- That can be problematic when the client IP is meaningful information for the proxied service to act upon, especially when it impacts security.
- The PROXY protocol is a well established solution to preserve the client IP when both the proxy and service have enabled the support.

Technical Details - HTTP vs TCP proxying

A key difference for how the network is proxied relates to the OSI Model:

- Layer 7 (Application layer protocols: SMTP / IMAP / HTTP / etc)
- Layer 4 (Transport layer protocols: TCP / UDP)

When working with Layer 7 and a protocol like HTTP, it is possible to inspect a protocol header like `Forwarded` (or it's predecessor: `X-Forwarded-For`). At a lower level with Layer 4, that information is not available and we are routing traffic agnostic to the application protocol being proxied.

A proxy can prepend the PROXY protocol header to the TCP/UDP connection as it is routed to the service, which must be configured to be compatible with PROXY protocol (often this adds a restriction that connections must provide the header, otherwise they're rejected).

Beyond your own proxy, traffic may be routed in the network by other means that would also rewrite this information such as Docker's own network management via `iptables` and `userland-proxy` (NAT). The PROXY header ensures the original source and destination IP addresses, along with their ports is preserved across transit.

## Configuration

### Reverse Proxy

The below guidance is focused on configuring Traefik, but the advice should be roughly applicable elsewhere (eg: NGINX, Caddy).

- Support requires the capability to proxy TCP (Layer 4) connections with PROXY protocol enabled for the upstream (DMS). The upstream must also support enabling PROXY protocol (which for DMS services rejects any connection not using the protocol).
- TLS should not be terminated at the proxy, that should be delegated to DMS (which should be configured with the TLS certs). Reasoning is covered under the ports section.

Traefik service

The Traefik service config is fairly standard, just define the necessary entrypoints:

compose.yaml

    services:
      reverse-proxy:
        image: docker.io/traefik:latest # 2.10 / 3.0
        # CAUTION: In production you should configure the Docker API endpoint securely:
        # https://doc.traefik.io/traefik/providers/docker/#docker-api-access
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock
        command:
          # Docker provider config:
          - --providers.docker=true
          - --providers.docker.exposedbydefault=false
          # DMS ports you want to proxy:
          - --entryPoints.mail-smtp.address=:25
          - --entryPoints.mail-submission.address=:587
          - --entryPoints.mail-submissions.address=:465
          - --entryPoints.mail-imap.address=:143
          - --entryPoints.mail-imaps.address=:993
          - --entryPoints.mail-pop3.address=:110
          - --entryPoints.mail-pop3s.address=:995
          - --entryPoints.mail-managesieve.address=:4190
        # Publish external access ports mapped to traefik entrypoint ports:
        ports:
          - "25:25"
          - "587:587"
          - "465:465"
          - "143:143"
          - "993:993"
          - "110:110"
          - "995:995"
          - "4190:4190"
        # An IP is assigned here for other services (Dovecot) to trust for PROXY protocol:
        networks:
          default:
            ipv4_address: 172.16.42.2

    # Specifying a subnet to assign a fixed container IP to the reverse proxy:
    networks:
      default:
        name: my-network
        ipam:
          config:
            - subnet: "172.16.42.0/24"

Extra considerations

- `--providers.docker.network=my-network` is useful when there is more than one network to consider.
- If your deployment has any other hops (an edge proxy, load balancer, etc) between the reverse proxy and the client, you'll need PROXY protocol support throughout that chain. For Traefik this additionally requires enabling PROXY protocol on your entry points.

Traefik labels for DMS
compose.yaml

    services:
      dms:
        image: ghcr.io/docker-mailserver/docker-mailserver:latest
        hostname: mail.example.com
        labels:
          - traefik.enable=true

          # These are examples, configure the equivalent for any additional ports you proxy.
          # Explicit TLS (STARTTLS):
          - traefik.tcp.routers.mail-smtp.rule=HostSNI(`*`)
          - traefik.tcp.routers.mail-smtp.entrypoints=smtp
          - traefik.tcp.routers.mail-smtp.service=smtp
          - traefik.tcp.services.mail-smtp.loadbalancer.server.port=25
          - traefik.tcp.services.mail-smtp.loadbalancer.proxyProtocol.version=2

          # Implicit TLS is no different, except for optional HostSNI support:
          - traefik.tcp.routers.mail-submissions.rule=HostSNI(`*`)
          - traefik.tcp.routers.mail-submissions.entrypoints=smtp-submissions
          - traefik.tcp.routers.mail-submissions.service=smtp-submissions
          - traefik.tcp.services.mail-submissions.loadbalancer.server.port=465
          - traefik.tcp.services.mail-submissions.loadbalancer.proxyProtocol.version=2
          # NOTE: Optionally match by SNI rule, this requires TLS passthrough (not compatible with STARTTLS):
          #- traefik.tcp.routers.mail-submissions.rule=HostSNI(`mail.example.com`)
          #- traefik.tcp.routers.mail-submissions.tls.passthrough=true

PROXY protocol compatibility

Only TCP routers support enabling PROXY Protocol (via `proxyProtocol.version=2`)

Postfix and Dovecot are both compatible with PROXY protocol v1 and v2.

Technical Details - Ports (Traefik config)

Explicit TLS (STARTTLS)

Service Ports: `mail-smtp` (25), `mail-submission` (587), `mail-imap` (143), `mail-pop3` (110), `mail-managesieve` (4190)

- Traefik expects the TCP router to not enable TLS (see "Server First protocols") for these connections. They begin in plaintext and potentially upgrade the connection to TLS, Traefik has no involvement in STARTTLS.
- Without an initial TLS connection, the `HostSNI` router rule is not usable (see "HostSNI & TLS"). This limits routing flexibility for these ports (eg: routing these ports by the FQDN to different DMS containers).

Implicit TLS

Service Ports: `mail-submissions` (465), `mail-imaps` (993), `mail-pop3s` (995)

The `HostSNI` router rule could specify the DMS FQDN instead of `*`:

- This requires the router to have TLS enabled, so that Traefik can inspect the server name sent by the client.
- Traefik can only match the SNI to `*` when the client does not provide a server name. Some clients must explicitly opt-in, such as CLI clients `openssl` (`-servername`) and `swaks` (`--tls-sni`).
- Add `tls.passthrough=true` to the router (this implicitly enables TLS).
  - Traefik should not terminate TLS, decryption should occur within DMS instead when proxying to the same implicit TLS ports.
  - Passthrough ignores any certificates configured for Traefik; DMS must be configured with the certificates instead (DMS can use `acme.json` from Traefik).

Unlike proxying HTTPS (port 443) to a container via HTTP (port 80), the equivalent for DMS service ports is not supported:

- Port 25 must secure the connection via STARTTLS to be reached publicly.
- STARTTLS ports requiring authentication for Postfix (587) and Dovecot (110, 143, 4190) are configured to only permit authentication over an encrypted connection.
- Support would require routing the implicit TLS ports to their explicit TLS equivalent ports with auth restrictions removed. `tls.passthrough.true` would not be required, additionally port 25 would always be unencrypted (if the proxy exclusively manages TLS/certs), or unreachable by public MTAs attempting delivery if the proxy enables implicit TLS for this port.

### DMS (Postfix + Dovecot)

Enable PROXY protocol on existing service ports

This can be handled via our config override support.

Postfix via `postfix-master.cf`:

docker-data/dms/config/postfix-master.cf

    smtp/inet/postscreen_upstream_proxy_protocol=haproxy
    submission/inet/smtpd_upstream_proxy_protocol=haproxy
    submissions/inet/smtpd_upstream_proxy_protocol=haproxy

`postscreen_upstream_proxy_protocol` and `smtpd_upstream_proxy_protocol` both specify the protocol type used by a proxy. `haproxy` represents the PROXY protocol.

Dovecot via `dovecot.cf`:

docker-data/dms/config/dovecot.cf

    haproxy_trusted_networks = 172.16.42.2

    service imap-login {
      inet_listener imap {
        haproxy = yes
      }

      inet_listener imaps {
        haproxy = yes
      }
    }

    service pop3-login {
      inet_listener pop3 {
        haproxy = yes
      }

      inet_listener pop3s {
        haproxy = yes
      }
    }

    service managesieve-login {
      inet_listener sieve {
        haproxy = yes
      }
    }

- `haproxy_trusted_networks` must reference the reverse proxy IP, or a wider subnet using CIDR notation.
- `haproxy = yes` for the TCP listeners of each login service.

Internal traffic (within the network or DMS itself)

- Direct connections to DMS from other containers within the internal network will be rejected when they don't provide the required PROXY header.
- This can also affect services running within the DMS container itself if they attempt to make a connection and aren't PROXY protocol capable.

A solution is to configure alternative service ports that offer PROXY protocol support (as shown next).

Alternatively routing connections to DMS through the local reverse proxy via DNS query rewriting can work too.

Configuring services with separate ports for PROXY protocol

In this example we'll take the original service ports and add `10000` for the new PROXY protocol service ports.

Traefik labels will need to update their service ports accordingly (eg: `.loadbalancer.server.port=10465`).

Postfix config now requires our `user-patches.sh` support to add new services in `/etc/postfix/master.cf`:

docker-data/dms/config/user-patches.sh

    #!/bin/bash

    # Duplicate the config for the submission(s) service ports (587 / 465) with adjustments for the PROXY ports (10587 / 10465) and `syslog_name` setting:
    postconf -Mf submission/inet | sed -e s/^submission/10587/ -e 's/submission/submission-proxyprotocol/' >> /etc/postfix/master.cf
    postconf -Mf submissions/inet | sed -e s/^submissions/10465/ -e 's/submissions/submissions-proxyprotocol/' >> /etc/postfix/master.cf
    # Enable PROXY Protocol support for these new service variants:
    postconf -P 10587/inet/smtpd_upstream_proxy_protocol=haproxy
    postconf -P 10465/inet/smtpd_upstream_proxy_protocol=haproxy

    # Create a variant for port 25 too (NOTE: Port 10025 is already assigned in DMS to Amavis):
    postconf -Mf smtp/inet | sed -e s/^smtp/12525/ >> /etc/postfix/master.cf
    # Enable PROXY Protocol support (different setting as port 25 is handled via postscreen), optionally configure a `syslog_name` to distinguish in logs:
    postconf -P 12525/inet/postscreen_upstream_proxy_protocol=haproxy 12525/inet/syslog_name=smtp-proxyprotocol

Dovecot is mostly the same as before:

- A new service name instead of targeting one to modify.
- Add the new port assignment.
- Set `ssl = yes` when implicit TLS is needed.

docker-data/dms/config/dovecot.cf

    haproxy_trusted_networks = 172.16.42.2

    service imap-login {
      inet_listener imap-proxied {
        haproxy = yes
        port = 10143
      }

      inet_listener imaps-proxied {
        haproxy = yes
        port = 10993
        ssl = yes
      }
    }

    service pop3-login {
      inet_listener pop3-proxied {
        haproxy = yes
        port = 10110
      }

      inet_listener pop3s-proxied {
        haproxy = yes
        port = 10995
        ssl = yes
      }
    }

    service managesieve-login {
      inet_listener sieve-proxied {
        haproxy = yes
        port = 14190
      }
    }

## Verification

Send an email through the reverse proxy. If you do not use the DNS query rewriting approach, you'll need to do this from an external client.

Sending a generic test mail through `swaks` CLI

Run a `swaks` command and then check your DMS logs for the expected client IP, it should no longer be using the reverse proxy IP.

    # NOTE: It is common to find port 25 is blocked from outbound connections, you may only be able to test the submission(s) ports.
    swaks --helo not-relevant.test --server mail.example.com --port 25 -tls --from hello@not-relevant.test --to user@example.com

- You can specify the `--server` as the DMS FQDN or an IP address, where either should connect to the reverse proxy service.
- `not-relevant.test` technically may be subject to some tests, at least for port 25. With the submission(s) ports those should be exempt.
- `-tls` will use STARTTLS on port 25, you can exclude it to send unencrypted, but it would still go through the same port/route being tested.
- To test the submission ports use `--port 587 -tls` or `--port 465 -tlsc` with your credentials `--auth-user user@example.com --auth-password secret`
- Add `--tls-sni mail.example.com` if you have configured `HostSNI` in Traefik router rules (SNI routing is only valid for implicit TLS ports).

Do not rely on local testing alone

Testing from the Docker host technically works, however the IP is likely subject to more manipulation via `iptables` than an external client.

The IP will likely appear as from the gateway IP of the Docker network associated to the reverse proxy, where that gateway IP then becomes the client IP when writing the PROXY protocol header.

## Security concerns

### Forgery

Since the PROXY protocol sends a header with the client IP rewritten for software to use instead, this could be abused by bad actors.

Software on the receiving end of the connection often supports configuring an IP or CIDR range of clients to trust receiving the PROXY protocol header from.

Risk exposure

If you trust more than the reverse proxy IP, you must consider the risk exposure:

- Any container within the network that is compromised could impersonate another IP (container or external client) which may have been configured to have additional access/exceptions granted.
- If the reverse proxy is on a separate network/host than DMS, exposure of the PROXY protocol enabled ports outside the network increases the importance of narrowing trust. For example with the known IPv6 to subnet Gateway IP routing gotcha in Docker, trusting the entire subnet DMS belongs to would wrongly trust external clients that have the subnet Gateway IP to impersonate any client IP.
- There is a known risk with Layer 2 switching (applicable to VPC networks, impact varies by cloud vendor):
  - Neighbouring hosts can indirectly route to ports published on the interfaces of a separate host system that shouldn't be reachable (eg: localhost `127.0.0.1`, or a private subnet `172.16.0.0/12`).
  - The scope of this in Docker is limited to published ports only when Docker uses `iptables` with the kernel tunable `sysctl net.ipv4.ip_forward=1` (enabled implicitly). Port access is via `HOST:CONTAINER` ports published to their respective interface(s), that includes the container IP + port.

While some concerns raised above are rather specific, these type of issues aren't exclusive to Docker and difficult to keep on top of as software is constantly changing. Limit the trusted networks where possible.

Postfix has no concept of trusted proxies

Postfix does not appear to have a way to configure trusted proxies like Dovecot does (`haproxy_trusted_networks`).

`postscreen_access_list` (or `smtpd_client_restrictions` with `check_client_access` for ports 587/465) can both restrict access by IP via a CIDR lookup table, however the client IP is already rewritten at this point via PROXY protocol.

Thus those settings cannot be used for restricting access to only trusted proxies, only to the actual clients.

A similar setting `mynetworks` / `PERMIT_DOCKER` manages elevated trust for bypassing security restrictions. While it is intended for trusted clients, it has no relevance to trusting proxies for the same reasons.

### Monitoring

While PROXY protocol works well with the reverse proxy, you may have some containers internally that interact with DMS on behalf of multiple clients.

Roundcube + Fail2Ban

You may have other services with functionality like an API to send mail through DMS that likewise delegates credentials through DMS.

Roundcube is an example of this where authentication is delegated to DMS, which introduces the same concern with loss of client IP.

- While this service does implement some support for preserving the client IP, it is limited.
- This may be problematic when monitoring services like Fail2Ban are enabled that scan logs for multiple failed authentication attempts which triggers a ban on the shared IP address.

You should adjust configuration of these monitoring services to monitor for auth failures from those services directly instead, adding an exclusion for that service IP from any DMS logs monitored (but be mindful of PROXY header forgery risks).