
Run a browser-based JMAP client for Stalwart, verify it locally, and make the working interface available remotely
Bulwark Webmail combines mail, calendars, contacts, and files in a self-hosted browser interface backed by Stalwart Mail Server. In this guide, we deploy the full Bulwark edition with Docker Compose, complete its setup wizard, confirm that the local HTTP endpoint works, and cover routine container operations and troubleshooting. Once the local service is healthy, we publish it through a Localtonet HTTP tunnel without requiring inbound router port forwarding, firewall changes, a public IP address, or a VPN.
π What's in this guide
Understand what Bulwark Webmail does
Bulwark Webmail is a self-hosted JMAP web client designed for Stalwart Mail Server. It gives users a browser interface for mail, calendars, contacts, and files. The full edition runs as a Node.js application and includes capabilities such as the setup wizard, an administration dashboard, OAuth support, plugins, and settings synchronization.
Bulwark is not a mail server. It does not replace Stalwart, create a complete mail-delivery system by itself, or independently provide SMTP mail transport. Stalwart remains the source of truth for accounts, messages, mail delivery, spam handling, calendars, address books, and file storage. Bulwark connects to that server over JMAP and presents the information in a unified browser application.
The Bulwark container can start without turning the host into a mail server. To sign in and use real accounts, you need an existing Stalwart deployment with working user accounts and an accessible JMAP endpoint. The documented compatibility information states that an existing Stalwart server should run version 0.16.6 or newer.
How the components fit together
A normal deployment has three distinct layers. Stalwart stores and processes the mail data. Bulwark provides the web interface and communicates with Stalwart. Localtonet can then provide a public HTTPS address for the local Bulwark HTTP endpoint. Keeping these responsibilities separate makes troubleshooting much easier.
| Component | Primary responsibility | What it does not replace |
|---|---|---|
| Stalwart Mail Server | Stores messages and accounts, handles mail-server functions, and provides the JMAP service used by Bulwark | It does not provide the Bulwark full-edition web interface by itself |
| Bulwark Webmail | Provides the browser interface for mail, calendars, contacts, and files | It is not a standalone SMTP mail server and does not replace Stalwart |
| Docker Compose | Defines and operates the Bulwark container on the host | It does not configure Stalwart accounts, DNS, or mail delivery |
| Localtonet HTTP tunnel | Connects a public HTTPS address to the reachable local Bulwark HTTP endpoint | It does not create the webmail service or configure its user authentication |
Full edition compared with Bulwark Lite
This tutorial uses the full Bulwark edition because its official quick start runs a Node.js container on port 3000 and provides the setup wizard and administration dashboard. Bulwark Lite is an alternative build containing static HTML and JavaScript. Lite can be placed on a static web host, but the browser then communicates directly with Stalwart and Stalwart must allow the relevant cross-origin requests.
| Deployment option | Best fit | Important characteristics |
|---|---|---|
| Full Bulwark container | Docker or Node.js deployments needing the complete server edition | Includes the setup wizard, admin console, OAuth, plugins, settings sync, web push, and update notices |
| Bulwark Lite | Environments that already have a static web host and use password sign-in | Runs as static files, has no setup wizard or admin console, and requires appropriate CORS configuration on Stalwart |
| Manual full-edition installation | Administrators who intentionally operate the Node.js application without Docker | Officially supported as an installation path, but its detailed procedure is outside the supplied evidence for this tutorial |
| Full edition served near Stalwart | Existing Stalwart environments using a conventional reverse-proxy architecture | Can be deployed next to Stalwart, but reverse-proxy and sub-path settings must follow Bulwark's current deployment documentation |
Prerequisites for this Docker Compose deployment
Complete the following checks before creating the Compose project. The first goal is to make Bulwark work entirely on the local machine. Remote publication should be treated as a separate step after the application and its Stalwart connection have been verified.
A host with Docker and Docker Compose
Use a machine where Docker Engine or Docker Desktop is installed and running. The modern Compose plugin uses the command docker compose. Confirm that the Docker daemon is available and that your user has permission to operate it:
docker --version
docker compose version
docker info
If the version commands work but docker info fails, the Docker service may not be running or the current account may not have permission to access it. Resolve that host-level problem before continuing.
A compatible Stalwart server
You need the URL of the Stalwart server that Bulwark should use. The documented compatibility baseline is Stalwart 0.16.6 or newer. The server should already have the accounts that users will use to sign in. Bulwark does not migrate an existing mailbox into Stalwart and does not manufacture a working mail system from an empty JMAP URL.
Verify the Stalwart side independently. At minimum, confirm that its administrative interface reports a compatible version, that the intended user accounts exist, and that the JMAP service is reachable from the relevant deployment environment. If Stalwart is on another device, container network, or hostname, account for DNS resolution, routing, certificates, and firewall policy between the systems.
Host port 3000 available for Bulwark
The official container quick start maps host port 3000 to container port 3000. The Compose definition in this guide preserves that mapping. Make sure another process or container is not already listening on the same host port.
On a Docker host, an existing container can be found with:
docker ps
Operating-system tools can also identify local listeners, but the exact command varies by platform. If port 3000 is already occupied, do not stop an unfamiliar service without understanding its role. You may choose another host port and still map it to container port 3000, but the official quick-start endpoint and the Localtonet examples in this guide assume host port 3000.
A Localtonet client for the remote-access stage
Install and run the Localtonet client on the Bulwark host or on another device that can reach the Bulwark host over the local network. The client establishes an outbound connection to our relay infrastructure, so the workflow does not require inbound router port forwarding, a public IP address, firewall changes, or a separate VPN.
A Localtonet authentication token identifies the device that runs the tunnel. Treat that token as a secret. Do not place it in the Compose file, screenshots, shell history, public issue reports, or this webmail application's settings.
Running the Localtonet client on the Docker host gives the tunnel a straightforward local target at 127.0.0.1:3000. A client on another device can target an IP address and port reachable from that device, but this adds LAN routing and host firewall variables to the setup.
Install Bulwark Webmail with Docker Compose

Bulwark's official quick start uses the published image ghcr.io/bulwarkmail/webmail:latest and maps port 3000 on the host to port 3000 in the container. The following minimal Compose file expresses that documented container configuration without adding unverified volumes, environment variables, health checks, or application flags.
Create a dedicated project directory
Keep the Compose definition in its own directory so that future start, stop, status, and log commands consistently target the same project.
Create the Compose definition
Define one Bulwark service using the official container image and the documented host-to-container port mapping of 3000:3000.
Validate the Compose configuration
Ask Docker Compose to parse the file before starting anything. This catches YAML indentation errors and unsupported syntax.
Start the Bulwark container
Launch the project in detached mode. Docker downloads the image if it is not already available on the host.
Check the service state and startup logs
Confirm that the container remains running, then inspect its recent output before opening the setup wizard.
1. Create the project directory
mkdir bulwark-webmail
cd bulwark-webmail
Create a file named compose.yaml in this directory:
services:
bulwark:
image: ghcr.io/bulwarkmail/webmail:latest
ports:
- "3000:3000"
This is intentionally minimal. The available evidence establishes the image and port mapping but does not establish a persistent-volume mount, a container data path, a required restart policy, or additional networking fields. Inventing those values could result in an invalid deployment or accidental data loss.
Bulwark stores mail data in Stalwart, but the full edition also has its own administrative configuration and synchronized settings capabilities. The evidence supplied for this article does not identify the container path that should be persisted. Before recreating a production container, consult the current Bulwark deployment documentation for its supported persistence model. Do not guess a volume destination.
2. Validate and start the project
Validate the effective Compose model:
docker compose config
If the output shows the bulwark service, the expected image, and the port mapping, start it:
docker compose up -d
Check whether the service is running:
docker compose ps
Then inspect its recent logs:
docker compose logs --tail=100 bulwark
The container should stay in a running state. A container that repeatedly exits has not reached the point where an HTTP tunnel can help. Diagnose the container locally before creating any public endpoint.
About the latest image tag
The official quick start uses ghcr.io/bulwarkmail/webmail:latest, which is convenient for an initial installation. However, a mutable tag can point to a different build after a future release. Production operators commonly select and test an explicit release tag so that deployments are reproducible.
We do not hardcode a version here because the correct current release can change, and the supplied release evidence does not establish that one historical version should be selected for every new installation. Choose an explicit currently supported release from Bulwark's release information if reproducibility is required. Container images and release files from version 1.12.0 onward include signed build provenance attestations, and release files also provide SHA-256 checksums.
Complete the Bulwark setup wizard
Open the following address in a browser on the Docker host:
http://localhost:3000
The first-launch wizard asks for the Stalwart server and an administration password. Enter the actual Stalwart address used by your deployment rather than copying a placeholder hostname. Set a unique, strong administration password and store it in an appropriate password manager.
Open the local Bulwark interface
Visit http://localhost:3000 from the Docker host. Confirm that the page belongs to the Bulwark container you just started.
Enter the Stalwart server
Supply the Stalwart server requested by the wizard. Use the real JMAP-capable server for this environment and make sure it is running Stalwart 0.16.6 or newer.
Set the Bulwark administration password
Use a password that is not shared with the Docker host, Stalwart account, Localtonet token, or any other administrative system.
Most installations can be configured through this wizard and changed later through the administration dashboard. Bulwark also recognizes environment-based configuration, which can be useful for immutable deployments. Documented variables include JMAP_SERVER_URL and APP_NAME.
Configuration precedence matters. When an environment variable and the saved administrative configuration set the same key, the value saved through the admin configuration takes priority. An environment variable therefore fills a value only when the admin configuration leaves it unset. If an environment change seems to be ignored, check the saved dashboard setting before assuming that Compose failed to pass the variable.
Finish the local wizard and verify the configured result before starting the Localtonet tunnel. A public endpoint created too early could expose an incomplete first-run workflow. Also avoid placing passwords in the Compose file unless the project's current documentation explicitly defines a secure, supported secret-management method.
What successful configuration should produce
A configured deployment should load the Bulwark interface, recognize the intended Stalwart server, and permit a valid Stalwart account to reach the appropriate webmail experience. Mailboxes, calendars, contacts, and files remain controlled by the permissions and data on Stalwart.
If the Bulwark page loads but account sign-in or data retrieval fails, the container's HTTP service is working. The remaining issue is likely in application configuration, Stalwart compatibility, account credentials, JMAP reachability, DNS, or certificate handling. This distinction is important because publishing a broken application through a tunnel only makes the same broken state remotely reachable.
Verify the local service before publishing it

Verification should proceed from the lowest layer upward. First check Docker, then the HTTP interface, then Bulwark's connection to Stalwart, and finally real account behavior. This sequence prevents network exposure from obscuring an application problem.
1. Confirm that the container remains running
docker compose ps
The service should not be continuously restarting or exiting. If it is unstable, inspect a live log stream:
docker compose logs -f bulwark
Press Ctrl+C to stop following the output. This ends the log viewer, not the detached container.
2. Confirm the local HTTP endpoint
Open http://localhost:3000 in a browser on the host. A successful page load proves that Docker published the port and that the application is answering HTTP requests. If a command-line HTTP client is installed, a header request can provide another basic reachability check:
curl -I http://localhost:3000
An HTTP response confirms that something is listening. The exact status can vary according to application state and redirects, so use the browser for functional verification rather than treating one specific status code as the only acceptable result.
3. Test the setup and administration workflow
Confirm that the setup wizard accepts the intended Stalwart server and administration password. After setup, ensure that the administration interface can be reopened as expected. Do not rely solely on seeing a Bulwark logo or sign-in page, since those observations do not prove that the JMAP connection works.
4. Sign in with a test account
Use a non-privileged Stalwart account suitable for testing. Verify the functions your deployment actually needs, such as loading the inbox, opening a message, displaying a calendar, showing contacts, or browsing available files. Use least privilege instead of testing ordinary webmail behavior with a server-wide administrator account.
5. Check behavior from the Localtonet client device
If the Localtonet client runs on the same host, test http://127.0.0.1:3000 there. If it runs on another LAN device, open the Bulwark host's reachable local IP address and port from that device. A failure at this stage is a local routing or host-access problem, not a relay problem.
The Bulwark HTTP service can be reachable while its Stalwart connection is misconfigured. Verify both layers. The tunnel needs only a reachable local HTTP target, but users need the complete Bulwark-to-Stalwart path to work.
Routine Docker Compose operations
Use commands from the directory containing compose.yaml. This helps Compose select the expected project instead of acting on an unrelated deployment.
View status
docker compose ps
Read logs
docker compose logs --tail=200 bulwark
To follow new output:
docker compose logs -f bulwark
Restart the existing container
docker compose restart bulwark
A restart is useful after a transient host or network problem. It should not be used to conceal a recurring configuration error. Inspect the logs if the same failure returns.
Stop and start without intentionally removing the container
docker compose stop
docker compose start
While Bulwark is stopped, the Localtonet tunnel may still be running, but its local target will be unavailable. Stop the tunnel too if remote access is not needed.
Plan updates carefully
An update normally involves selecting a desired image, pulling it, and recreating the service. The precise production procedure must account for Bulwark's supported configuration persistence and backup requirements. Because the supplied evidence does not identify the correct persistent container path, this article does not prescribe a destructive recreation command as a universally safe update method.
Before updating, record the currently deployed image, review Bulwark's release notes, understand whether the selected release introduces configuration changes, and verify the supported backup or persistence procedure. Test the new build locally before restoring public access.
Pulling latest can introduce a different application build. Do not assume that recreating the container is risk-free when the deployment's supported persistence path has not been established. Select, verify, and test the intended release, and follow the current Bulwark updating documentation.
Publish Bulwark through a Localtonet HTTP tunnel

After the local browser interface and Stalwart integration work, the Bulwark endpoint is ready to become a Localtonet HTTP tunnel target. Our client creates an outbound connection to a Localtonet relay server. The resulting tunnel provides a public HTTPS address without requiring an inbound router rule, a public IP address, firewall changes, or VPN setup.
An HTTP tunnel is the appropriate family for this workflow because Bulwark presents an HTTP web application on local port 3000. Do not configure a File Server tunnel for this purpose. File Server publishes a local folder, while Bulwark is a running web service with its own application behavior.
You can review the current fields and interface in our Localtonet HTTP tunnel documentation. Available relay servers, regions, options, and plan availability can change, so select them from the current dashboard rather than copying a hardcoded server code from an article.
Install and run the Localtonet client
Run our client on the Bulwark Docker host or another trusted device that can reach the host's HTTP port. Keep the client running for as long as remote access is required.
Authenticate or select the client device
Use the device-specific authentication token associated with the client. Do not publish, share, or embed the token in the Bulwark configuration.
Select an available relay server
Choose from the servers or regions currently available in the Localtonet dashboard. Availability can vary, so this guide does not hardcode a server code.
Create an HTTP tunnel to Bulwark
Configure the local target as 127.0.0.1 and port 3000 when our client runs on the Docker host. If the client runs elsewhere, use the Bulwark host address that is actually reachable from the client device.
Start the tunnel
Creating a tunnel does not start it. Use the Start control, then wait for the tunnel and selected client device to report a connected state.
Open and test the assigned public address
Use the public HTTPS URL provided for the running tunnel. Test it in a separate browser session and confirm that the Bulwark sign-in flow and Stalwart-backed functions still work.
Choose the HTTP process type
Localtonet HTTP tunnels can use Random Sub Domain, Custom Sub Domain, or Custom Domain process types. All three publish the same local content through a public HTTPS address. The difference is how the public hostname is selected.
| Process type | Typical use | Important consideration |
|---|---|---|
| Random Sub Domain | Initial testing or temporary remote access | Uses a generated public address and avoids committing to a permanent hostname during verification |
| Custom Sub Domain | A selected Localtonet subdomain where supported | Availability and plan applicability must be checked in the current dashboard |
| Custom Domain | Deployments that need an organization-controlled hostname | Follow current Localtonet DNS instructions rather than guessing records or targets |
Understand the tunnel lifecycle
The public endpoint depends on two active components: the selected Localtonet client must be connected, and the tunnel must be running. Creating the tunnel configuration alone does not make Bulwark public. Likewise, stopping the client, shutting down its host, stopping the tunnel, or stopping the Bulwark container makes the remote service unavailable.
When access is temporary, stop the tunnel after use. Delete it if the public configuration is no longer required. This reduces unnecessary exposure and keeps the dashboard aligned with active services.
Secure the published webmail interface
Webmail is a sensitive service. A tunnel solves connectivity, but it does not remove the need for application authentication, account protection, monitoring, software updates, or least-privilege administration. Treat the public URL as internet-facing even when it is shared with only a small group.
Anyone who obtains the URL can attempt to reach the exposed interface. Keep Bulwark and Stalwart authentication enabled, use strong credentials and two-factor authentication where appropriate, and apply any available access controls according to your organization's policy.
Protect administrative surfaces
The full Bulwark edition includes an administration dashboard. Set its password during local setup, verify that the password works, and avoid using an ordinary mailbox password for administration. Do not publish screenshots containing server addresses, account names, tokens, or private configuration values.
Protect the Stalwart connection
Bulwark's public page can be healthy while the path to Stalwart is insecure or misconfigured. Use the Stalwart server address appropriate for your environment, validate certificates where HTTPS is used, and avoid weakening certificate checks merely to bypass a hostname or trust problem. Correct DNS and certificate deployment instead.
Limit unnecessary network exposure
The Compose mapping 3000:3000 follows Bulwark's documented quick start. Depending on Docker and host networking, it may listen beyond the loopback interface. Review the host's actual listening interfaces and local security policy. If you later choose a loopback-only bind, confirm that this syntax is supported by your Docker environment and that the Localtonet client runs on the same host.
Troubleshooting Bulwark and Localtonet
The container exits immediately
Start with the Compose status and logs:
docker compose ps
docker compose logs --tail=200 bulwark
Check for a malformed Compose file, image download failure, unsupported architecture, missing Docker daemon access, or an application startup error. Validate the file again with docker compose config. Do not create a tunnel until the container remains running.
Docker reports that port 3000 is already allocated
Another process or container is using the host port. Run docker ps and inspect the host's listeners. Stop or reconfigure only a service you recognize. If you intentionally select another host port, keep container port 3000 as the destination and use the selected host port in the browser and Localtonet target.
The browser cannot open localhost:3000
Confirm that the browser is running on the Docker host. The name localhost always refers to the device on which the browser runs. If you open that URL from another computer, it points to that other computer rather than the Docker host.
Next, verify that the container is running and that Compose reports the port mapping. Review the application logs. Host security software or a conflicting service may also affect access.
The Bulwark page loads, but login or mailbox data fails
This usually means the HTTP service is healthy but the application-to-Stalwart workflow is not. Confirm the Stalwart version, JMAP server address, account credentials, account status, name resolution, network route, and certificate validity. If an environment variable was changed, remember that a saved admin value takes precedence for the same setting.
The environment variable appears to have no effect
Bulwark gives saved administration configuration priority over environment variables when both define the same key. Inspect the corresponding setting in the admin dashboard. An environment variable such as JMAP_SERVER_URL only supplies the value when the admin configuration leaves it unset.
Bulwark works locally but the Localtonet URL does not
Check the layers in order:
- Confirm that Bulwark still opens locally on port 3000.
- Confirm that the Localtonet client is running and connected.
- Confirm that the tunnel has been started, not merely created.
- Confirm that the tunnel target uses the correct local IP address and port.
- If the client runs on another device, test the target directly from that device.
- Confirm that the public address belongs to the active tunnel configuration.
With a same-host client, 127.0.0.1:3000 is the direct target for this deployment. With a separate client device, 127.0.0.1 would refer to that client device, not the Docker host, so use an address that is reachable over the local network.
The public page opens, but some functions fail
Reproduce the same action through the local URL. If it also fails locally, investigate Bulwark or Stalwart. If it works locally but fails only through the public address, compare the browser console, application logs, hostname assumptions, callback configuration, and any feature that depends on an externally visible origin.
OAuth, OIDC, embedded sign-on, reverse-proxy sub-paths, and custom callback behavior can require deployment-specific configuration. The supplied evidence confirms that Bulwark supports OAuth and related deployment guides, but it does not establish exact callback paths or environment variables. Follow Bulwark's current authentication documentation rather than inventing values.
The tunnel disappeared after a reboot
The public endpoint is available only while the selected Localtonet client is connected and the tunnel is running. Also confirm that Docker and the Bulwark container have restarted. This guide does not add an unverified Compose restart policy, so inspect the service state explicitly after a host reboot.
An update caused a startup failure
Identify the exact image that was deployed, review the corresponding release notes, and inspect container logs. Avoid repeatedly recreating the service until you understand how its administrative configuration is persisted. If you used latest, record the resolved image information so that the affected build can be identified.
Frequently asked questions
Is Bulwark Webmail a complete mail server?
No. Bulwark is the browser client. Stalwart is the mail server and remains responsible for messages, accounts, SMTP functions, spam processing, calendars, contacts, and file storage. Install or identify Stalwart first, then configure Bulwark to use its JMAP service.
Which Stalwart version does Bulwark require?
The documented compatibility information says that an existing deployment should run Stalwart 0.16.6 or newer. Check current Bulwark compatibility guidance before upgrading either component in a production environment.
Why does this Compose file contain only an image and port mapping?
Those are the details established by Bulwark's official container quick start: the image is ghcr.io/bulwarkmail/webmail:latest, and host port 3000 maps to container port 3000. The supplied evidence does not establish a volume path, health check, restart policy, or additional required flags, so we do not invent them.
Should I use the latest image tag in production?
The official quick start uses latest, which is convenient for installation. A production deployment often benefits from an explicit, tested release tag because latest can change. Select a currently supported release, review its notes, verify persistence and backup procedures, and test the update before public use.
Can I use Bulwark Lite instead of the container?
Yes, but it is a different deployment model. Bulwark Lite is a static-file build without the full edition's setup wizard, admin console, OAuth, plugins, settings sync, office editing, or web-push features. The browser communicates directly with Stalwart, so the Stalwart HTTP configuration must allow the required cross-origin access.
Does Localtonet replace Bulwark or Stalwart authentication?
No. Our HTTP tunnel provides connectivity from a public HTTPS address to the local Bulwark service. Keep application authentication enabled, use strong unique credentials, enable two-factor authentication where appropriate, and follow least-privilege practices for Stalwart and Bulwark administration.
Do I need router port forwarding or a public IP address?
No. The Localtonet client establishes an outbound connection to our relay server. This allows the running Bulwark endpoint to receive remote browser traffic without inbound router port forwarding, firewall changes, VPN setup, or a public IP address.
What local target should I enter in Localtonet?
If our client runs on the same Docker host, use local IP 127.0.0.1 and port 3000. If it runs on another device, use an IP address and port for the Bulwark host that the client device can actually reach. Test that address from the client device before starting the tunnel.
Why is the public address unavailable even though I created a tunnel?
Creating a Localtonet tunnel does not start it. The selected device must be connected, the tunnel must be started, and Bulwark must be listening on the configured local target. The public address remains available only while those required components are running.
Publish your verified Bulwark interface with Localtonet
Once Bulwark works locally and can communicate with Stalwart, create an HTTP tunnel to the local port, start it, and use the assigned public HTTPS address for remote access. Keep authentication enabled and stop the tunnel whenever public access is no longer required.
Get Started Free β