WebAdmin UI is not reachable from outside the container

Issue Description

I installed stalwart a few months before. Everything is up and running. Now I wanted to add an email adress, but get an empty reply from calling the admin UI.

Expected Behavior

UI should be accessible from the host

Actual Behavior

UI is accessible only in the stalwart container

Reproduction Steps

I run the interface behind an nginx proxy with the following configuration:

services:
  stalwart-mail:
    image: stalwartlabs/stalwart:latest
    container_name: stalwart-mail
    restart: unless-stopped
    volumes:
      - ./data:/opt/stalwart
      - /var/www/stalwart/mailserver/certs/:/certs/:ro
    ports:
      - "25:25"     # SMTP
      - "465:465"   # SMTPS (SMTP über SSL/TLS)
      - "993:993"   # IMAPS (IMAP über SSL/TLS)
      - "995:995"   # POP3 / TLS
      - "587:587"   # ESMTP (explicit TLS => STARTTLS)
      - "127.0.0.1:8181:8080" # Stalwart Web-Admin-Oberfläche (HTTP)

With nginx I forward requests from https://mysubdomain.xx/ to http://127.0.0.1:8181. This setting worked in the past. Now I get a 502 from Nginx. I tested the following:

docker exec -it stalwart-mail curl localhost:8080

This works and gets Html-Output. Of course I installed Curl first in the container.

From the host I call

curl localhost:8181

This gets me an empty reply:

curl: (52) Empty reply from server

I tried reconfiguring the port to be reachable not only from localhost:

    ports
      ...
      - "8181:8080" # Stalwart Web-Admin-Oberfläche (HTTP)

With the same result. What can I do to make the requests working?

Greetings
mirko

Relevant Log Output

n.a.

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

I remember having similar reverse proxy issues (I use Traefik) after upgrading Stalwart from 0.15.x to 0.16.0. This was mainly due to a bug that was fixed in 0.16.1. Also, there are general changes to be aware of if you use a reverse proxy. Have a look at stalwart/UPGRADING/v0_16.md at main · stalwartlabs/stalwart · GitHub

@dvex1091 Thanks a lot for your reply. As far as I can determine without the UI the version of the container image is v0.15.

"Labels": {
	"org.opencontainers.image.created": "2026-02-14T20:20:59.790Z",
	"org.opencontainers.image.description": "All-in-one Mail & Collaboration server. Secure, scalable and fluent in every protocol (IMAP, JMAP, SMTP, CalDAV, CardDAV, WebDAV).",
...
	"org.opencontainers.image.title": "stalwart",
...
	"org.opencontainers.image.version": "v0.15"
},

Stalwart UI only listens on 2 endpoints: /admin and /account. For testing with curl, you might try curl http://localhost:8081/admin/login.

@mirkomaty The label shows that you are still on v0.15, so this is not an update issue. I agree with aaron (adjust the port in the curl command).

I would also suggest tagging the Stalwart version in your compose file to prevent unplanned upgrades, since upgrading to 0.16.x requires a data migration.

@aaron & @dvex1091 Thank you for your replies. I tried curl http://localhost:8181/admin/login and got:
curl: (52) Empty reply from server

I have a question regarding this topic. There are a zillion container images out there that implement web interfaces. It takes only some minutes to run such containers behind a proxy like Nginx. I would like to understand what makes Stalwart’s web interface so unique that it cannot be run behind a proxy just as easily.

What is missing, in my view, is a description detailing exactly what requirements this interface imposes—specifically, which headers need to be passed, and so on—so that one can (1) understand the underlying mechanics and (2) apply the correct configuration. Evidently, this documentation is lacking, given the sheer volume of questions found online regarding Stalwart’s web interface. And the situation looks even worse when it comes to JMap.

Can someone shed some light on this topic?

@dvex1091 Thanks for the hint of using specific tags to prevent unplanned upgrades. I installed the image on May 21, 2026. According to Docker Hub, the current version at that time should have been 0.15.5.

Two things going on:

First, the container is genuinely still on v0.15 (the image label is v0.15, not 0.16.x); the recommendation from @aaron / @dvex1091 to pin a specific tag in your compose is the right one. The latest tag doesn’t auto-update; once you upgrade, you will need to follow the 0.16 migration steps because the on-disk format changed.

Second, on the empty-reply: on 0.15, port 8080 is a plain-HTTP listener and 8081 is HTTPS-implicit (a TLS listener). Your compose maps host 8181 → container 8080, which should be plain HTTP. The path /admin/login is the right one (the UI lives under /admin and /account; everything else returns the JSON 404 you saw earlier). “Empty reply from server” usually means the listener accepted the TCP connection and then closed without writing a response, which is what happens when a TLS listener gets plain HTTP, or when the listener is bound to a specific interface. From inside the container, run ss -tlnp (or netstat -tlnp) and confirm 8080 is bound to 0.0.0.0:8080, not 127.0.0.1:8080 or a specific bridge IP.

On the broader “why is this harder than other containers”: short version, Stalwart serves not just an admin UI but also JMAP, EAS-ish autodiscovery, and a few other HTTP-fronted protocols on the same listener, and each has slightly different expectations about Host header rewriting and X-Forwarded-For propagation. The 0.16 upgrade doc has a “reverse proxy deployments” section that lists exactly which headers need to flow through, which we have been tightening as more setups come in.

I’ve been facing a similar issue. I run Stalwart using Docker Compose and Caddy with the L4 module.

Migration from v15 to v16 went quite fine and all my endpoints work fine. Only issue is the HTTP/HTTPS endpoints. After certain reboots, Stalwart seems to serve HTTPS just fine. Then some time later, when I need the admin UI or to just access the 443 port, I no longer get JSON 404’s, I get a failed TLS handshake:

* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (OUT), TLS alert, decode error (562):
* TLS connect error: error:0A000126:SSL routines::unexpected eof while reading
* closing connection #0
curl: (35) TLS connect error: error:0A000126:SSL routines::unexpected eof while reading

HTTP endpoint just returns an empty page (same as the OP):

* Request completely sent off
* Empty reply from server
* shutting down connection #0
curl: (52) Empty reply from server

These commands were run on the host machine. Like the OP, the curl commands within the container work just fine. The main difference in my issue is that on initial boot (sometimes, it’s an unreliable pattern), Stalwart seems to listen fine on these endpoints outside the container.

Here is my setup:

Note on the port setup, I have several services wanting 443 on the same machine, so while the mail ports are exposed directly without a reverse proxy, docker exposes the container’s 443 on the host’s 444 which then is then going through Caddy (without any TLS involvement since Stalwart handles TLS). Any non-Stalwart 443 requests to 8443 where other listeners exist to distinguish those hosts.

Docker Compose (it’s managed by systemd, don’t worry about restart):

services:
  stalwart:
    image: stalwartlabs/stalwart:latest
    container_name: stalwart
    restart: "no"
    ports:
      - "25:25"
      - "444:443"
      - "587:587"
      - "465:465"
      - "143:143"
      - "993:993"
      - "4190:4190"
      - "8080:8080"
    volumes:
      - ./data/stalwart-config:/etc/stalwart
      - ./data/stalwart/data:/var/lib/stalwart
      - ./certs/:/certs/:ro
    environment:
      TZ: ${TZ:-UTC}

Caddy:

{
  layer4 {
     :443 {
        @mail tls sni mail.example.com
        route @mail {
           proxy {
             upstream localhost:444
           }
        }
        route {
           proxy {
             upstream localhost:8443
           }
        }
     }
  }
}

@stalwart Thanks for your reply. The netstat result is as follows:



Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.11:45279        0.0.0.0:*               LISTEN      -
tcp6       0      0 :::993                  :::*                    LISTEN      1/stalwart
tcp6       0      0 :::995                  :::*                    LISTEN      1/stalwart
tcp6       0      0 :::587                  :::*                    LISTEN      1/stalwart
tcp6       0      0 :::465                  :::*                    LISTEN      1/stalwart
tcp6       0      0 :::443                  :::*                    LISTEN      1/stalwart
tcp6       0      0 :::110                  :::*                    LISTEN      1/stalwart
tcp6       0      0 :::25                   :::*                    LISTEN      1/stalwart
tcp6       0      0 :::143                  :::*                    LISTEN      1/stalwart
tcp6       0      0 :::4190                 :::*                    LISTEN      1/stalwart
tcp6       0      0 :::8182                 :::*                    LISTEN      1/stalwart
tcp6       0      0 :::8080                 :::*                    LISTEN      1/stalwart

The 0.16 upgrade doc has a “reverse proxy deployments” section that lists exactly which headers need to flow through

Does anything of this section apply to 0.15?

As you can see above, my docker-compose.yml shows, that all mail ports are bound directly to the outside world. Mail transfer and IMAP are working fine. All I need is a working Nginx configuration for the Web UI without using proxy protocol.

This is my currently running container:

af7235b596e9   stalwartlabs/stalwart:v0.15.5                “/bin/sh /usr/local/…”   5 minutes ago   Up 5 minutes          110/tcp, 143/tcp, 0.0.0.0:25->25/tcp, [::]:25->25/tcp, 0.0.0.0:465->465/tcp, [::]:465->465/tcp, 0.0.0.0:587->587/tcp, [::]:587->587/tcp, 0.0.0.0:993->993/tcp, [::]:993->993/tcp, 0.0.0.0:995->995/tcp, [::]:995->995/tcp, 443/tcp, 10.8.1.1:4190->4190/tcp, 0.0.0.0:8181->8080/tcp, [::]:8181->8080/tcp   stalwart-mail

If I start curl the following output happens:

curl -v localhost:8181/admin/login

Trying 127.0.0.1:8181…

Connected to localhost (127.0.0.1) port 8181 (#0)

GET /admin/login HTTP/1.1
Host: localhost:8181
User-Agent: curl/7.81.0
Accept: /

Empty reply from server

Closing connection 0
curl: (52) Empty reply from server

If it’s any help, I managed to enable logs and access to the HTTP listener when running in recovery mode, and saw that Stalwart throttles and blocks the IP that docker uses to proxy traffic into the container. Placing the Docker IP on the whitelist seemed to fix things.

recovery mode

Thanks für the suggestion. I tried to start the container with recovery mode with no success. I checked the environment variable STALWART_RECOVERY_MODE inside the container and it was set successfully.

Well, the fact that this discussion has already spanned several posts—and I haven’t made any progress yet—indicates that there is something fundamentally wrong with the listener architecture or it’s configuration.

It obviously takes some kind of “voodoo” just to get the configuration UI up and running. It was working previously; maybe after a system restart, a state arose where the Web UI is now simply returning an “empty reply” across the container-to-host boundary.

I am still desperately searching for a way to get the Web UI working again. If anyone has any further ideas on this subject, I would be extremely grateful.

Otherwise, I would be faced with the task of setting up a completely new mail server—something I would very much like to avoid.

I’d suggest first running Stalwart without any proxies and getting that working before introducing a reverse proxy into the mix. Set the log level to trace and verify that the WebUI is being downloaded and unpacked correctly, then check your logs after accessing /admin.
It’s also worth reading the troubleshooting section on reverse proxies in the install instructions. Stalwart’s WebUI is an OIDC app that needs to support and route requests to multiple IdP backends. Because of this, using a reverse proxy requires tweaking certain settings, such as the public URL if it differs from the default. Unfortunately this can’t be simplified further, as there are some settings Stalwart cannot infer on its own when running behind a proxy.​​​​​​​​​​​​​​​​

@mdecimus Thanks a lot for your answer.

I’m using the docker image. I guess, that the WebUI is part of the image. Since the UI worked in the past it should have been downloaded before.

then check your logs after accessing /admin

So I execute a shell with docker exec -it stalwart-mail bash. Then I install curl and execute: curl localhost:8080/admin. The result is valid Html.

root@9fe28eaeb252:/opt/stalwart# curl localhost:8080/admin
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

<script type="module">
...</script>

    <!-- Favicon for browsers -->
...
<body>
</body>

</html>

Now I start curl outside of the container. The port 8080 in the stalwart-mail container is mapped to 8181 on the host, because 8080 is in use by another application on the same machine.

On the host I enter curl -v localhost:8181/admin:

*   Trying 127.0.0.1:8181...
* Connected to localhost (127.0.0.1) port 8181 (#0)
> GET /admin HTTP/1.1
> Host: localhost:8181
> User-Agent: curl/7.81.0
> Accept: */*
>
* Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server

Now I’m getting a step further. I mapped the port 8080 to the public port 8181. Entering the URL http://mail.mydomain.com:8181 get’s me the admin UI, so I can change the configuration. That’s a great success. Now, what I want to achieve, is to enter a special URL like https://mailconfig.mydomain.com to get to the admin UI. There the proxy comes into play. It should forward the request to the container.

I first try to simulate the situation with curl.

curl -v mail.mydomain.de:8181 works

*   Trying a.b.c.d:8181...
* Connected to mail.mydomain.com (a.b.c.d) port 8181 (#0)
> GET / HTTP/1.1
> Host: mail.mydomain.com:8181
> User-Agent: curl/x.y.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/html
< content-length: 2116
< date: Mon, 18 May 2026 15:15:10 GMT
... Lots of Html

curl -v -H “Host: mail.mydomain.com:8181” localhost:8181 doesn’t work

*   Trying 127.0.0.1:8181...
* Connected to localhost (127.0.0.1) port 8181 (#0)
> GET / HTTP/1.1
> Host: mail.mydomain.com:8181
> User-Agent: curl/x.y.0
> Accept: */*
>
* Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server

What’s the difference?

Check your server logs to confirm but it looks like your Docker or reverse proxy address was blocked. Try starting in recovery mode to regain access, or you can use the CLI to add your Docker internal IP addresses to the allow list.

Thanks for your reply. Is it possible to change the allow list in the config.toml file? The file contains the following entries (among others)

server.listener.http.bind = "[::]:8080"
server.listener.http.protocol = "http"
server.listener.http.trusted-proxies = ["127.0.0.1"]

The trusted-proxies line came from one of my attempts and made no difference.