Static hosting
Bulwark Lite is a folder. This page is about getting that folder built the way you want it, putting it on a host, and making deep links work there. If you haven't read what Lite is, start there. If your mail server is Stalwart 0.16.6 or later, 1.0 included, you can also skip the web host and let Stalwart serve Lite: see Install on Stalwart. If you run containers anyway, there is a ready-made Lite image.
Three steps
-
Get the zip. Every release attaches
bulwark-lite-<version>.zip. Unzip it and upload the whole folder so that/index.htmland/config.jsonare served from your site root (or from the sub-path you built it for, see below). -
Edit
config.json: setjmapServerUrlto your mail server, andappNameto what the tab should say. -
Allow the browser to talk to the mail server. In Stalwart's web admin, turn on Permissive CORS policy under Settings > Network > HTTP > Security, or use the CLI:
stalwart-cli update Http --field usePermissiveCors=true stalwart-cli create Action/ReloadSettingsOr an equivalent reverse-proxy rule that allows your Lite origin with the
AuthorizationandContent-Typeheaders on/.well-known/jmap,/jmap/*,/api/auth,/auth/tokenand, for Stalwart 1.0,/auth/register.
That is the whole install. The rest of this page is for the cases where the defaults aren't right.


config.json
The file is read by the browser at runtime, so edits take effect on the next page load and never need a rebuild. Keys the app reads; anything else is ignored:
| Key | Default | Meaning |
|---|---|---|
appName | Bulwark Webmail in the shipped file (Webmail if the key is missing) | Name in the tab, the login page and the manifest |
jmapServerUrl | empty | Your mail server, e.g. https://mail.example.com |
allowCustomJmapEndpoint | true when no server is configured, else false | Show a server field on the login page |
jmapServers | [] | A fixed list of servers to offer instead; entries need id, label, url and may carry domains |
jmapServerAutoPickByDomain | false | Pick from jmapServers by the domain of the typed address |
rememberMeEnabled | true | Remember sign-ins; false ends every session with the tab. Servers without Stalwart's token login are never remembered |
loginShowRememberMe | false | Show the "remember me" box so users choose |
demoMode | false | Built-in demo account, no server needed |
faviconUrl, appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl | Bulwark's | Branding images, as URLs or paths under the folder |
loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl | empty | Login page footer |
loginUsernamePlaceholder | translated example | Placeholder of the login email field, in every language |
loginLogoMaxHeight, loginLogoMaxWidth | natural size | Any CSS length |
loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion | true | Hide parts of the login page |
loginShowTokenLogin | false | Show the access token sign-in |
embeddedMode, parentOrigin | false, empty | Framing a password login inside another page |
noThirdPartyTraffic | false | true keeps the browser off every built-in outside host |
oauthRevokeOnSignOut | true | false keeps the refresh token valid at the server on sign-out, so Stalwart 1.0 doesn't ask for consent again |
sourceCodeUrl | empty | Source code link on the About screen; empty links the upstream repository |
helpUrl, accessibilityStatementUrl, accessibilityFeedbackUrl, easyReadUrl, signLanguageUrl | empty | Help and accessibility links on the login page, in the account menu and in settings |
These are the full edition's branding variables, camel-cased. Whatever the file says, the flags that only work with a server behind them are pinned off: OAuth, auto-SSO, settings sync, the Stalwart account features, the JMAP passthrough, dev mode.
An optional policy.json next to it carries the same settings policy the admin console would write in the full edition (default settings, restrictions, theme policy). Plugins and sidebar apps stay off regardless of what it says.
Error reporting
From the next release, after 1.19.1, policy.json can also name an error tracker (Bugsink, GlitchTip, Sentry). Browsers then send their errors straight to it, with mail content removed first:
{
"errorReporting": {
"dsn": "https://<key>@errors.example.com/1",
"sampleRate": 1,
"environment": "production"
}
}
sampleRate (0 to 1, default 1) and environment (default production) are optional; without a valid dsn nothing is sent. Lite has no server, so there are no per-day error counts and no admin page for them. Add the tracker's origin to connect-src wherever your host sets the security headers: the generated _headers, nginx and Caddy examples allow only the JMAP server when one was baked in, and in the container image it goes into LITE_CSP_CONNECT_SRC. A build made with LITE_NO_THIRD_PARTY_TRAFFIC=true carries a per-page policy that allows only the JMAP server, so it can't send error reports. In the Stalwart bundle, policy.json is part of the read-only zip. What is removed and sent is on Monitoring.
Build inputs
The release zip is built for the site root with no server URL. To bake in different defaults, dispatch the Lite: Static bundle workflow in the repository with its inputs, or build locally. Either way the knobs are environment variables read at build time:
| Variable | Effect |
|---|---|
LITE_JMAP_SERVER_URL | Default jmapServerUrl written into config.json (still editable afterwards) |
LITE_APP_NAME | Default appName |
LITE_ALLOW_CUSTOM_ENDPOINT | Default allowCustomJmapEndpoint; on by default when no server URL is given |
LITE_REMEMBER_ME | Default rememberMeEnabled |
LITE_DEMO_MODE | true builds the static demo, which needs no server |
LITE_NO_THIRD_PARTY_TRAFFIC | Default noThirdPartyTraffic |
LITE_LOCALES | Comma-separated subset of locales to export, e.g. en,de. Default: all 29 |
NEXT_PUBLIC_BASE_PATH | Mount the app under a sub-path, e.g. /webmail. Baked into every asset URL, so it can't be changed after the build |
NEXT_PUBLIC_DEFAULT_LOCALE | Locale the root page falls back to when the browser's languages match nothing exported |
GIT_COMMIT | Commit shown in lite-build.json and the About screen |
To build locally, use a disposable checkout. The build deletes the server-only trees in place and refuses to run in a working tree unless told it is throwaway:
git worktree add ../bulwark-lite HEAD
cd ../bulwark-lite && npm ci
LITE_JMAP_SERVER_URL=https://mail.example.com npm run build:lite -- --in-place
npm run lite:serve # http://localhost:4173/
npm run build:lite -- --dry-run prints what would be removed and built without touching anything.
What lands in the folder
| File | Purpose |
|---|---|
<locale>/mail/, calendar/, contacts/, files/, settings/, login/ | One shell per locale and surface; <locale>/mail/index.html serves every mail deep link |
index.html | Root shim: picks the visitor's locale in the browser and jumps to it |
404.html | Replays a deep link in the browser on hosts with no rewrite rules |
config.json, policy.json | Runtime configuration, described above |
manifest.webmanifest | Web app manifest, so the app can be added to a home screen |
_redirects, _headers | Netlify and Cloudflare Pages rewrite rules and security headers |
nginx.conf.example, Caddyfile.example | Ready-made server blocks for the two |
LITE-README.md, lite-build.json | The three-step readme, and version / commit / locales / base path of this build |
Deep links
A URL such as /en/mail/thread/abc has no file behind it. It is served by the shell at /en/mail/index.html, which reads the path and opens the thread. The host has to know that, in one of three ways:
- Rewrite rules: the host serves the owning surface's
index.htmlfor anything below/<locale>/<surface>/. This is what the snippets below do, and what_redirectsdoes on Netlify and Cloudflare Pages. The URL opens in one page load, without a flash. - The
404.htmlreplay: on a host with no rewrite support (GitHub Pages, a plain S3 bucket) the shipped404.htmlparks the requested path insessionStorage, loads the right shell, and the app picks the path up from there. It works, with one extra page load. - A sub-path build: if Lite is mounted under
/webmail, build it withNEXT_PUBLIC_BASE_PATH=/webmailso the shells and rules carry the prefix.
Host snippets
nginx
# Bulwark Lite - nginx example. Unzip the archive into /var/www/bulwark-lite
# so that /index.html is served at /; deep links below a surface
# are rewritten to that surface's shell.
server {
listen 80;
server_name webmail.example.com;
root /var/www/bulwark-lite;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# connect-src names the JMAP server this bundle was built for. Every page
# also carries a <meta> policy that allows only its own inline scripts.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' https: data:; connect-src 'self' *; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; media-src 'self' blob:" always;
# Hashed assets never change. (add_header inside a location replaces the
# inherited set, so the security headers are repeated here.)
location /_next/static/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' https: data:; connect-src 'self' *; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; media-src 'self' blob:" always;
}
# Older mime.types files do not know the PWA manifest extension.
location = /manifest.webmanifest {
types { }
default_type application/manifest+json;
}
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
location ~ ^/(?<locale>[a-zA-Z-]+)/(?<surface>mail|calendar|contacts|files|settings)(/.*)?$ {
try_files $uri $uri/ $uri/index.html /$locale/$surface/index.html;
}
location / {
try_files $uri $uri/ $uri/index.html =404;
error_page 404 /404.html;
}
}
Caddy
# Bulwark Lite - Caddy example. Unzip the archive into /var/www/bulwark-lite
# so that /index.html is served at /.
webmail.example.com {
root * /var/www/bulwark-lite
encode gzip
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
# connect-src names the JMAP server this bundle was built for.
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' https: data:; connect-src 'self' *; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; media-src 'self' blob:"
}
@manifest path /manifest.webmanifest
header @manifest Content-Type application/manifest+json
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
@surface path_regexp surface ^/([a-zA-Z-]+)/(mail|calendar|contacts|files|settings)(/.*)?$
handle @surface {
try_files {path} {path}/ {path}/index.html /{re.surface.1}/{re.surface.2}/index.html
}
handle {
try_files {path} {path}/ {path}/index.html
}
file_server
# Anything else is a real 404, answered by the shim that replays parked deep links.
handle_errors {
@notfound expression {http.error.status_code} == 404
rewrite @notfound /404.html
file_server
}
}
Both are what the 1.19.1 zip ships as nginx.conf.example and Caddyfile.example, with the sub-path already substituted if you built for one. The release zip has no server URL baked in, so its connect-src is 'self' *; a build with LITE_JMAP_SERVER_URL names that origin instead.
Netlify and Cloudflare Pages
Drop the folder in and you are done. Both hosts read the shipped _redirects, which maps every /<locale>/<surface>/* to its shell with a 200, and _headers, which sets the security headers and a Content-Security-Policy.
GitHub Pages, S3, and other hosts without rewrites
Upload the folder. Deep links go through the 404.html replay described above. For GitHub Pages under a project path (https://you.github.io/webmail/), build with NEXT_PUBLIC_BASE_PATH=/webmail.
Container image
If you would rather run a container than upload a folder, ghcr.io/bulwarkmail/webmail-lite is the same export behind an unprivileged nginx: no Node.js at runtime, runs as uid 101, listens on port 8080 over IPv4 and IPv6. Releases are tagged like the main image (latest, 1.19.1, 1.19, 1), and ghcr.io/bulwarkmail/webmail-lite-beta:latest follows main.
Configure it by mounting your own config.json (and policy.json, if you need one) over the baked-in file:
services:
webmail-lite:
image: ghcr.io/bulwarkmail/webmail-lite:latest
ports:
- "8080:8080"
environment:
# connect-src of the Content-Security-Policy. Defaults to "*" because the
# image cannot know your mail server; pin it once config.json does.
- LITE_CSP_CONNECT_SRC=https://mail.example.com
volumes:
- ./config.json:/usr/share/nginx/html/config.json:ro
# Optional hardening: nginx only writes to these two.
read_only: true
tmpfs:
- /tmp
- /etc/nginx/conf.d:uid=101,gid=101
restart: unless-stopped
{
"appName": "Example Mail",
"jmapServerUrl": "https://mail.example.com",
"allowCustomJmapEndpoint": false,
"rememberMeEnabled": true
}
The nginx config is generated from the same routing rules as nginx.conf.example (deep links below a surface fall back to that surface's shell, everything else is a real 404). It also adds what a static host's _headers would: the Content-Security-Policy and the other security headers, immutable caching for /_next/static/, revalidation for the shells and config.json, and gzip.
- Terminate TLS in the reverse proxy in front of it.
- The image serves from
/, so give it a host of its own rather than a sub-path. - The browser still talks to the mail server directly, so the CORS setting applies as for any static host.
To bake in different defaults or a locale subset, build the image yourself. The build args are the LITE_* build inputs:
docker build -f Dockerfile.lite --build-arg LITE_LOCALES=en,de -t bulwark-lite .
Security headers
_headers carries the recommended set: nosniff, X-Frame-Options: SAMEORIGIN (PDF previews in Safari need it), a referrer policy, a permissions policy, and a Content-Security-Policy whose connect-src is 'self' plus your JMAP server's origin when the build had one baked in, and 'self' * otherwise. Tighten it to your server's origin if you prefer a strict policy. The export contains inline hydration scripts, so script-src has to allow 'unsafe-inline'; that is a property of the static export, not a choice.
Updating
There is no update channel in Lite and no in-app notice. Download the new zip, keep your config.json (and policy.json if you wrote one), and upload over the old folder. lite-build.json tells you which version is deployed.
With the container image, pull the new tag and recreate the container; the mounted config.json carries over.
Related pages
- Bulwark Lite - what it is and what it leaves out
- Install on Stalwart - the no-web-server alternative
- Stalwart setup - the CORS setting in context
- Troubleshooting - the CORS and deep-link failures