Docs / DeploymentEdit on GitHub
Lite only. This page applies to Bulwark Lite and has no counterpart in the full edition.

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.

Three steps

  1. Get the zip. Every release attaches bulwark-lite-<version>.zip. Unzip it and upload the whole folder so that /index.html and /config.json are served from your site root (or from the sub-path you built it for, see below).

  2. Edit config.json: set jmapServerUrl to your mail server, and appName to what the tab should say.

  3. Allow the browser to talk to the mail server. In Stalwart:

    [http]
    permissive-cors = true
    

    Or an equivalent reverse-proxy rule that allows your Lite origin with the Authorization and Content-Type headers on /.well-known/jmap, /jmap/*, /api/auth and /auth/token.

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:

KeyDefaultMeaning
appNameBulwark WebmailName in the tab, the login page and the manifest
jmapServerUrlemptyYour mail server, e.g. https://mail.example.com
allowCustomJmapEndpointtrue when no server is configured, else falseShow 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
jmapServerAutoPickByDomainfalsePick from jmapServers by the domain of the typed address
rememberMeEnabledtrueOffer "remember me" (hidden anyway on servers without Stalwart's token login)
demoModefalseBuilt-in demo account, no server needed
faviconUrl, appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrlBulwark'sBranding images, as URLs or paths under the folder
loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrlemptyLogin page footer
loginLogoMaxHeight, loginLogoMaxWidthnatural sizeAny CSS length
loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersiontrueHide parts of the login page
embeddedMode, parentOriginfalse, emptyFraming a password login inside another page

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 dashboard would write in the full edition (default settings, restrictions, theme policy). Plugins and sidebar apps stay off regardless of what it says.

Build inputs

The release zip is built for the site root with no server URL. To bake in different defaults, dispatch the Build Static Lite workflow in the repository with its inputs, or build locally. Either way the knobs are environment variables read at build time:

VariableEffect
LITE_JMAP_SERVER_URLDefault jmapServerUrl written into config.json (still editable afterwards)
LITE_APP_NAMEDefault appName
LITE_ALLOW_CUSTOM_ENDPOINTDefault allowCustomJmapEndpoint; on by default when no server URL is given
LITE_REMEMBER_MEDefault rememberMeEnabled
LITE_DEMO_MODEtrue builds the static demo, which needs no server
LITE_LOCALESComma-separated subset of locales to export, e.g. en,de. Default: all 27
NEXT_PUBLIC_BASE_PATHMount 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_LOCALELocale the root page falls back to when the browser's languages match nothing exported
GIT_COMMITCommit 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

FilePurpose
<locale>/mail/, calendar/, contacts/, files/, settings/, login/One shell per locale and surface; <locale>/mail/index.html serves every mail deep link
index.htmlRoot shim: picks the visitor's locale in the browser and jumps to it
404.htmlReplays a deep link in the browser on hosts with no rewrite rules
config.json, policy.jsonRuntime configuration, described above
manifest.webmanifestWeb app manifest, so the app can be added to a home screen
_redirects, _headersNetlify and Cloudflare Pages rewrite rules and security headers
nginx.conf.example, Caddyfile.exampleReady-made server blocks for the two
LITE-README.md, lite-build.jsonThe three-step readme, and version / commit / locales / base path of this build

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.html for anything below /<locale>/<surface>/. This is what the snippets below do, and what _redirects does on Netlify and Cloudflare Pages. One page load, no flash.
  • The 404.html replay. On a host with no rewrite support (GitHub Pages, a plain S3 bucket) the shipped 404.html parks the requested path in sessionStorage, 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 with NEXT_PUBLIC_BASE_PATH=/webmail so the shells and rules carry the prefix.

Host snippets

nginx

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 DENY always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;
    # See _headers for a Content-Security-Policy that matches your JMAP server.

    location /_next/static/ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # /<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

webmail.example.com {
    root * /var/www/bulwark-lite
    encode gzip

    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy strict-origin-when-cross-origin
    }

    @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 /404.html
    }

    file_server
}

Both are what the zip ships as nginx.conf.example and Caddyfile.example, with the sub-path already substituted if you built for one.

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.

Security headers

_headers carries the recommended set: nosniff, X-Frame-Options: DENY, a referrer policy, a permissions policy, and a Content-Security-Policy whose connect-src is your JMAP server's origin when the build had one baked in, and * 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.