Installation¶
One server per customer. It needs Docker (or Podman), a fixed IPv4 address and a domain. For ten users a small VPS is enough (2 vCPU, 4 GB RAM, 80 GB SSD). Secure a new VPS first: Setting up a VPS – first steps.
1. Server and DNS¶
Check beforehand: the provider must not block port 25, and you must be able to set the reverse DNS record (PTR) of the IP.
| Record | Name | Value |
|---|---|---|
| A | mail.firma.de |
IP of the server |
| A | kontext.firma.de |
IP of the server |
| A | dokumente.firma.de |
IP of the server (only with Documents) |
| A | mta-sts.firma.de |
IP of the server (MTA-STS, see below) |
| A | openpgpkey.firma.de |
IP of the server (OpenPGP keys via WKD) |
| MX | firma.de |
10 mail.firma.de |
| TXT | firma.de |
v=spf1 mx -all |
| TXT | <selector>._domainkey.firma.de |
DKIM key from Stalwart |
| TXT | _dmarc.firma.de |
v=DMARC1; p=none; rua=mailto:dmarc@firma.de (later quarantine) |
| PTR | IP of the server | mail.firma.de (set at the provider) |
On top of that, for encryption in transit (concept): MTA-STS and TLS-RPT
require other mail servers to deliver to firma.de only encrypted, and report problems. Stalwart
shows the matching records under Domains → firma.de → DNS records
(_mta-sts.firma.de, mta-sts.firma.de, _smtp._tls.firma.de) and writes the policy;
Caddy serves it at https://mta-sts.firma.de (this needs MAIL_DOMAIN=firma.de in
deploy/.env). With DNSSEC at the provider, DANE comes on top (a
TLSA record, also from Stalwart). The company admin checks which of these are set.
Cloudflare
If the DNS is at Cloudflare, all records for mail and Kontext must be "DNS only" (grey cloud). No mail and no JMAP push gets through the Cloudflare proxy.
2. Starting¶
# The configuration (compose.yaml, .env.example, Caddyfile, backups); the programs come from Docker Hub
git clone https://github.com/firsttris/kontext /opt/kontext && cd /opt/kontext
cp deploy/.env.example deploy/.env
chmod 600 deploy/.env # only root may read the secrets
nano deploy/.env # domain, mail server name, secret, backup target
docker compose -f deploy/compose.yaml up -d # pulls the images from Docker Hub and starts them
For KONTEXT_SECRET, use a long random string (openssl rand -base64 48); it signs the
sessions and encrypts the Stalwart tokens. Replace all change-me placeholders: Kontext does not
start with them, and backup.sh does not back up. Leave STALWART_ADMIN_KEY empty for now; it is
added in step 3 (Turning on the company admin).
STALWART_BLOB_KEY (openssl rand -base64 32) encrypts the storage; keep it in the password
manager (Encrypting and compressing the storage).
All settings are listed under Settings at a glance. For calls in the chat, also
set LIVEKIT_SECRET and open TCP 7881 and UDP 7882 in the firewall (see
Calls). Caddy gets the certificate for
KONTEXT_DOMAIN by itself. With Podman, the Quadlet files are in
deploy/quadlet/ (copy them to /etc/containers/systemd/, systemctl daemon-reload); the domain
and addresses are written in each file itself, and each service gets only its own
settings (kontext.env, livekit.env … with chmod 600), none gets deploy/.env.
3. Setting up Stalwart¶
Stalwart's own admin interface listens only on the server itself (127.0.0.1:8080), never on the
internet. From your own computer, open an SSH tunnel, then http://localhost:8080 in the browser:
ssh -L 8080:127.0.0.1:8080 root@<server>
If you use Tailscale, enter the server's Tailscale address instead (TAILSCALE_IP in
deploy/.env, with Quadlet in stalwart.container) and reach it at
http://<tailscale-ip>:8080. On the first start, a temporary admin password is in the log
(docker compose -f deploy/compose.yaml logs stalwart, with Podman podman logs stalwart); after
every restart before the setup there is a new one.
- Create the domain
firma.de, generate a DKIM key and add it to the DNS as a TXT record. - TLS for
mail.firma.devia ACME, most simply with DNS-01 (Cloudflare API token), because port 443 belongs to Caddy. Careful: with DNS management, Stalwart itself publishes the records that are ticked under Publish DNS records – after the initial setup with a token, that includes MX, SPF and DMARCp=reject. If the domain's mail still runs elsewhere, it is switched over at once. Until the migration, tick only DKIM (without the interface). - Create port 587 (submission with STARTTLS) under Network → Listeners: Stalwart's default only has 465, and many mail programs use 587.
- Run the setup command – required, not optional: it registers
Kontext as the OAuth client (step 12), allows directory queries (step 13) and creates the role,
account and API key for the company admin; enter the key as
STALWART_ADMIN_KEY, then create the boss's account. The boss then creates further employees and shared mailboxes in Kontext themselves. - Under Settings → Network → HTTP → General, section Proxy, choose X-Forwarded-For header
as the Client IP source (
clientIpSource: xForwardedFor) and leave Trusted proxy hops at 1 (forwardedHops), so that Stalwart sees the real IP addresses for CalDAV/CardDAV and autoconfig (through Caddy). Stalwart takes the last entry, which Caddy sets. - Under Security → Allowed IPs, enter the container network
10.89.250.0/24(AllowedIp). Kontext signs in everyone from its one address; without this entry Stalwart blocks that address after 100 failed attempts a day (authBanRate) – and with it Kontext for everyone. Against password guessing, Kontext slows things down itself: 10 failed attempts per sign-in name in 15 minutes, plus 10 sign-ins per IP address per minute. - Under Security, set a ban duration (
authBanPeriod,scanBanPeriod, e.g. one day). Without a duration, a ban lasts forever. - Under Search, set the default language to German (
de-DE). Stalwart then builds German word stems, even for short mails whose language it cannot reliably detect. - Under JMAP, set the concurrent requests (
maxConcurrentRequests) to 16. Otherwise Stalwart allows 4 per person – for all tabs and devices together; Kontext asks for more at once on start (the folders of each mailbox, the list, the conversations), and Stalwart rejects whatever goes beyond that. Kontext retries a request rejected this way a few times after a short pause (src/jmap/client.ts); only then does it show an error. - In the calendar settings (
x:CalendarScheduling), turn off HTTP RSVP (httpRsvpEnable: false), but leave scheduling itself (enable) on. Kontext sends invitations with its own buttons; Stalwart's buttons would point to an address where Caddy answers (see Invitations). The buttons in Kontext's invitations need the company admin (the key below). - In the SMTP authentication settings (
x:MtaStageAuth,saslMechanisms), delete the second rule, which offersoauthbearerandxoauth2on 587 without TLS, so that onlylocal_port != 25 && is_tls→[plain, login, oauthbearer, xoauth2]remains. Otherwise a token would travel in plain text if someone strips STARTTLS from the connection. Kontext itself sends through JMAP, not through SMTP. - Kontext as an OAuth client – required, otherwise nobody can sign in; the
setup command does this. By hand: under Management → Directory → OAuth
Clients, create a client with the Client ID
kontext, the Redirect URIhttps://kontext.invalid/oauth(never called) and the scope Full (full), without a secret. Stalwart 1.0 only accepts clients it knows, and gives a token only the scopes the client allows;fullgives Kontext's token all the rights of the account that the settings and the company admin need (see Sign-in). Only a superuser may set the scopefull. If the client is missing, Kontext writesStalwart lehnt die Anmeldung für Kontext ab (Unknown client id)("Stalwart rejects the sign-in for Kontext") to the log when someone signs in; if the scope is missing,… ab (invalid_scope). - Allow directory queries – the setup command does this too; by hand under
Settings → Sharing (
x:Sharing,allowDirectoryQueries: true), then reload the settings (Reload). Required: without it Kontext finds no colleagues (sharing, new chat, free times when inviting); Stalwart 1.0 otherwise rejectsPrincipal/query(The administrator has disabled directory queries).
The setup command¶
What Kontext needs in Stalwart is set up by one command in the Kontext container. It asks for
Stalwart's admin login (the name, e.g. admin@firma.de, and the password, which is not shown);
the login stays in memory only, none of it is written or printed:
docker compose -f deploy/compose.yaml exec kontext bun server/setup-stalwart.ts
podman exec -it kontext bun server/setup-stalwart.ts # with Quadlet
It makes sure that
- the OAuth client
kontextwith the redirect URIhttps://kontext.invalid/oauthand the scopefullexists (step 12); an older client withoutfullgets the scope added; - directory queries are allowed, and then reloads the settings (step 13);
- the role
Kontext-Serverwith exactly the permissions from Turning on the company admin exists (it inherits User), plus the accountkontext-server@firma.dewith this role and a random password, and an API key for this account; - the storage is encrypted and compressed (Encrypting and compressing the storage).
It prints the new key once, as the line STALWART_ADMIN_KEY=API_…: put it into deploy/.env
(with Quadlet into deploy/kontext.env) and restart Kontext
(docker compose -f deploy/compose.yaml up -d, with Quadlet systemctl restart kontext).
The command only changes what is missing: if the client, role, account or key already exist, it
says so and at most adds missing permissions or the scope; it never creates a second key. That
is why it belongs to every update (Updates). If the key is lost, delete it in
Stalwart on the account kontext-server@ and run the command again. If Stalwart has several
domains, --domain firma.de picks the one for the account the first time; after that it keeps to
the account's domain. Without a terminal (in a script, say)
it takes the login from the environment variable STALWART_ADMIN (name:password). What exactly
it does is in server/stalwart-setup.ts.
Stalwart lets the admin pass on only permissions it has itself. If Stalwart was updated from a
version before 1.0.2, its admin role lacks the encryption permissions (sysEncryption…,
sysExternalPublicKey…). The command then sets everything else up and leaves out only the
permissions for encrypting by Stalwart, with a hint
saying so. To use that, give the admin role these permissions, as Stalwart's
changelog for 1.0.2 describes,
and run the command again.
Then sign in at https://kontext.firma.de. On the phone, choose "Add to Home Screen" in the
browser: Kontext then starts like an app and stays signed in.
Calls¶
Calls and meetings in the chat run over LiveKit, which compose.yaml starts with the profile calls
(COMPOSE_PROFILES=calls in .env; .env.example has calls,documents). This needs:
LIVEKIT_SECRETin.env: at least 32 characters (openssl rand -base64 48). Kontext and LiveKit use the same one.- TCP 7881 and UDP 7882 open in the server's firewall (and at the provider). Sound and
picture go through them; joining the call runs through Caddy (
https://kontext.firma.de/rtc). - LiveKit finds the server's public IP by itself (STUN). If the server sits behind NAT, enter
node_ip: <public IP>underrtcindeploy/livekit.yamland setuse_external_ip: false.
With Podman: copy deploy/quadlet/livekit.container as well, put LIVEKIT_KEYS="kontext: <secret>" in
/opt/kontext/deploy/livekit.env and the same secret as LIVEKIT_API_SECRET in
kontext.env. Without a secret, calls stay off and everything else runs as before.
For ten people the small VPS is enough; a video call with three people needs about 3–5 Mbit/s per participant in each direction at the server.
Telephony¶
For the company number to ring in Kontext as well and for calling out of Kontext, you need
LiveKit SIP and Redis on the server (already part of the profile calls) and a phone box in the office.
On the server: additionally open TCP 5060 (SIP from the boxes) and
UDP 20000–20100 (voice) in the firewall. Kontext registers the boxes with LiveKit SIP itself;
Settings → Telephony shows whether that worked. The telephony settings belong to the
company admin and therefore also need STALWART_ADMIN_KEY.
With Podman, redis.container and livekit-sip.container go to /etc/containers/systemd/
alongside livekit.container (see Calls), plus /opt/kontext/deploy/livekit-sip.env
with SIP_CONFIG_BODY on one line and the LiveKit secret as api_secret. The commands
for this are in Setting up a VPS, step 9; afterwards
systemctl daemon-reload and systemctl start redis livekit livekit-sip.
In the Fritzbox: Telefonie → Telefoniegeräte → Neues Gerät einrichten → „Telefon (mit und ohne Anrufbeantworter)“ → „LAN/WLAN (IP-Telefon)“ (Telephony → Telephony Devices → Configure New Device → Telephone (with and without answering machine) → LAN/WLAN (IP telephone)), name "Kontext". Note the user name and password, and set which numbers the device responds to and which one it calls with. For direct extensions, create another device of this kind per number that only responds to that number and calls with it, and enter it in Kontext under "Other numbers".
In Kontext: Settings → Telephony: enter the Fritzbox's IP as its address (usually
192.168.178.1), the user name and password from the Fritzbox, choose who should ring,
then "Pair box". A name like fritz.box works too: the box resolves it to IPv4 itself,
because it also points to the Fritzbox's IPv6 address, and Asterisk only speaks IPv4. The
page shows by itself as soon as the box is connected and registered with the Fritzbox.
The box: a device on the office network that is always on – a used thin client (Futro S740,
Wyse 5070), a Raspberry Pi with an SSD, the company server or a NAS with Docker. Ideally on a cable.
Only one file goes onto the device, deploy/box.compose.yaml – downloaded (with the version in
the path matching the Kontext server) or with the whole repository (git clone, then
-f deploy/box.compose.yaml):
mkdir -p /opt/kontext-box && cd /opt/kontext-box
curl -fsSLO https://raw.githubusercontent.com/firsttris/kontext/v0.1.6/deploy/box.compose.yaml
echo KONTEXT_VERSION=0.1.6 > .env # the same version as the server; without this line: latest
KONTEXT_URL=https://kontext.firma.de PAIRING_CODE=K7QM-4XPA \
docker compose -f box.compose.yaml up -d
The code is shown in Kontext under Settings → Telephony → "Pair box" and is only needed on the
first start. box.compose.yaml takes the image tristanteu/kontext-box in the version
from KONTEXT_VERSION (Compose reads the .env in the file's folder), otherwise latest. For an
update, raise the version there, then docker compose -f box.compose.yaml pull and
docker compose -f box.compose.yaml up -d; the box does not update itself.
The box needs a fixed address on the LAN (in the Fritzbox under Heimnetz → Netzwerk → Gerät → „Diesem Netzwerkgerät immer die gleiche IPv4-Adresse zuweisen“, i.e. Home Network → Network → device → "Always assign this network device the same IPv4 address"). It needs no open ports to the outside, only outgoing access to Kontext (HTTPS, 443) and to the Kontext server on TCP 5060 and UDP 20000–20100. If a firewall runs on the device, allow 5070 TCP+UDP, 10000–10200 UDP and 8089 TCP from the LAN. SIP on 5070 only listens after pairing; before that there is only the status page.
Desk phones (Snom): create them in Kontext under Telephony → Desk phones with their MAC address,
then in the phone (web interface → Advanced → Update) enter
http://<box>:8089/snom/{mac}.xml as the settings URL and restart it; it fetches its account and
settings from the box. For this the box needs a fixed address on the LAN. It hands out the password
only on the first request; after a reset of the phone, delete the desk phone in Kontext and
create it again.
With Podman: copy deploy/quadlet/kontext-box.container to /etc/containers/systemd/
(it can also be downloaded on its own as above), systemctl daemon-reload, systemctl start kontext-box;
after a reboot the box starts by itself. The file names Image=…/kontext-box:latest; better
enter the server's version there (kontext-box:0.1.6). For an update, raise the version,
systemctl daemon-reload, podman pull docker.io/tristanteu/kontext-box:<version> and
systemctl restart kontext-box – with latest, a restart does not fetch a new image. Started without a code, the box shows a form for Kontext's
address and the code at http://<box>:8089; with a code, KONTEXT_URL and PAIRING_CODE are in /etc/kontext-box.env.
It runs in the host network (SIP with the Fritzbox on 5070, voice on 10000–10200 on the LAN).
Building the box yourself (say, for a version that is not on Docker Hub yet), from v0.1.4 on – the box up to v0.1.3 does not load its configuration from Kontext and never registers with the Fritzbox:
git clone --depth 1 --branch v0.1.4 https://github.com/firsttris/kontext.git /opt/kontext-box-src
cd /opt/kontext-box-src
podman build -f box/Dockerfile -t kontext-box:0.1.4 .
In the Quadlet file it is then Image=localhost/kontext-box:0.1.4 (Podman puts localhost/ in
front of locally built images); with Docker it is kontext-box:0.1.4, and docker build instead of podman build.
Checking: curl http://127.0.0.1:8089/ shows "Kontext-Telefonbox", and
podman exec kontext-box asterisk -rx "module show like res_pjsip.so" shows res_pjsip.so.
The message "There are no local system nameservers configured, resorting to system resolution"
on start is harmless: Asterisk on Alpine does not read the nameservers itself and then uses
the system's.
If the box does not register with the Fritzbox (with Docker, docker instead of podman):
podman exec kontext-box asterisk -rx "pjsip show registrations"shows the registration. "No objects found": Asterisk has not loaded the configuration – the status page then shows the reason next to "Konfiguration" (configuration).Registered: all good.- "Rejected" with "No response received" in the log (
podman logs kontext-box): the Fritzbox never answered. Enter the Fritzbox's IP as its address;ping 192.168.178.1checks whether it can be reached. - "Rejected" with
403 Forbidden: the user name or password does not match the IP phone in the Fritzbox (the box then only tries again after 5 minutes). ss -lunp | grep 5070shows whether the box listens on 5070; a firewall on the device must let 5070 and 10000–10200 through from the LAN.
Documents¶
The "Documents" module is Schreibzeug, a separate
service with its own data and versions. compose.yaml starts it with the profile documents
(COMPOSE_PROFILES=calls,documents in .env, as in .env.example). This needs:
- an address of its own under the same domain, such as
dokumente.firma.de(an A record like Kontext's), asSCHREIBZEUG_URL=https://dokumente.firma.dein.env. Caddy gets the certificate for it by itself. The same domain keeps the frame and the sign-in free of detours (no third-party cookies). SCHREIBZEUG_CLIENT_SECRETin.env: at least 32 characters (openssl rand -base64 48). With it, Schreibzeug signs in to Kontext; Kontext registers Schreibzeug as a trusted client on start, without a consent page.
Whoever is signed in to Kontext is then signed in to Documents too; attachments can be filed there and
documents attached to mails. Every shared mailbox gets a folder of its own there for its
people; for this, Kontext reads the members with STALWART_ADMIN_KEY, as for the company admin. Without SCHREIBZEUG_URL the module is missing, and everything else runs as before.
Schreibzeug also runs on its own at its address, with "Sign in with Kontext".
With Podman: copy deploy/quadlet/schreibzeug.container as well, enter SCHREIBZEUG_URL,
SCHREIBZEUG_EMBED_ORIGIN=https://kontext.firma.de,
OIDC_ISSUER=https://kontext.firma.de/api/auth and OIDC_CLIENT_SECRET (the same secret) in
/opt/kontext/deploy/schreibzeug.env, and SCHREIBZEUG_URL and SCHREIBZEUG_CLIENT_SECRET in kontext.env too.
Without Documents: with Compose, take documents out of COMPOSE_PROFILES and delete the two lines
SCHREIBZEUG_URL and SCHREIBZEUG_CLIENT_SECRET from .env; leaving them empty would also work, because
compose.yaml then gives Caddy a placeholder (https://schreibzeug.internal) that answers
nobody. With Quadlet, do not copy schreibzeug.container, leave the two lines out of kontext.env
and delete the line Environment=SCHREIBZEUG_URL=… in caddy.container: Caddy reads
the address there, not from kontext.env, and would otherwise keep getting a certificate for it.
The server stays lean: editing and text recognition run in the browser.
Location suggestions¶
In the "Location" field of an appointment, Kontext suggests addresses. For this, the Kontext server asks
Photon (komoot, data from OpenStreetMap) at
https://photon.komoot.io/api/ – this is on without any setting. What someone types as a location thus goes
from the server to komoot (not the browser's address). With PLACES_URL= (empty) in .env or
kontext.env the suggestions are off, and the field still takes any text. You enter your own Photon
(say, with the data for Germany only) as PLACES_URL=https://photon.firma.de/api/.
komoot's public service is meant for moderate use; Kontext only asks after a
pause in typing and from three letters on.
Turning on the company admin¶
Under Settings → Company, the boss (or the office manager) creates employees and shared
mailboxes, resets passwords and the sign-in with a code, and locks accounts. For this,
the Kontext server needs an API key from Stalwart that may only manage accounts
(STALWART_ADMIN_KEY).
Without the key, a lot is missing
Kontext starts without STALWART_ADMIN_KEY too, but then these are missing:
- the company admin entirely – employees, shared mailboxes, company signature, backup, telephony settings and the migration;
- Other mailboxes (Gmail, GMX …): the entry is missing from the settings, because the server puts the fetched mails into the mailbox with the key;
- the reply buttons in invitations: Kontext sends them without Yes / No / Maybe, because the reply is entered with the key (Invitations);
- in Documents, the folders per shared mailbox: Kontext reads the members with the key.
That is why it is part of the setup (step 4 under Setting up Stalwart).
The setup command creates the role, account and key (steps 1–3); by hand in Stalwart's admin interface it goes like this:
- Create a role
Kontext-Server: it inherits the role User and additionally gets the permissionssysAccountGet,sysAccountQuery,sysAccountCreate,sysAccountUpdate,sysRoleGet,sysRoleCreate,sysDomainGet,sysAuthenticationGet,sysIndexQueueStatusGet,sysEncryptionGet,sysEncryptionUpdate,sysExternalPublicKeyGet,sysExternalPublicKeyCreate,sysExternalPublicKeyDestroy,sysActionCreate,actionReloadSettingsandimpersonate. WithsysIndexQueueStatusGetthe company admin shows while Stalwart is building the search index. With thesysEncryption…andsysExternalPublicKey…permissions it switches encryption by Stalwart and manages the recipients' keys;sysActionCreatewithactionReloadSettingslets it have Stalwart reload its settings afterwards. Withimpersonatethe server works in the employees' mailboxes (out of office, read access to shared mailboxes) and enters replies to invitations; the app never shows the boss anyone else's mail in doing so. - Create an account
kontext-server@firma.dewith this role (instead of User) and a long random password. - With this account, generate an API key (Credentials → API Key, permissions
Inherit). It starts with
API_; enter it indeploy/.envasSTALWART_ADMIN_KEYand restart Kontext (docker compose -f deploy/compose.yaml up -d); with Quadlet indeploy/kontext.env, thensystemctl restart kontext. Without the interface, the same works through Stalwart's API (Setting up a VPS, step 10). - On the first call, Kontext creates the role
Firmenverwaltung (Kontext)(company admin). Give the boss's account in Stalwart this role in addition to User. After that, they can pass it on to other people in Kontext themselves.
Stalwart does not let the key grant permissions it does not have itself; so an employee never becomes an admin through it. Kontext only shows and changes accounts with the normal role User. But the key can read all mailboxes and change the password of Stalwart's admin accounts (Stalwart does not check the permissions of the target account here) – so treat the key like an admin password.
Migration¶
Under Settings → Company → Migration, the boss brings over the employees' data:
- Connect mailbox for Gmail, GMX, WEB.DE, Telekom, IONOS, STRATO or another IMAP server. Gmail needs an app password (turn on 2-Step Verification in the Google account, then App passwords), GMX and WEB.DE need IMAP access turned on. The sync runs every 15 minutes until the migration is finished; after that, set up forwarding at the old provider or switch the MX record of your own domain. For this, the server needs outgoing port 993.
- Microsoft 365: once per customer, in the
Microsoft Entra admin center under App registrations, create an app
"Kontext Umzug", under API permissions add the application permissions
Mail.Read,Contacts.Read,Calendars.ReadandUser.Read.Allfor Microsoft Graph (optionallyMailboxSettings.Read, so that Outlook categories keep their colours) and grant admin consent, and under Certificates & secrets create a client secret. Enter the directory ID, application ID and secret on the first "Connect mailbox → Microsoft 365". The app can read all mailboxes of the tenant; after the migration, delete the secret or remove the app. - Upload file: Outlook data file (
.pst), contacts (.vcf) and calendars (.ics), for example from the Google export (Google Contacts → Export → vCard, Google Calendar → Settings → Export). The file stays in the data volume until the end of the migration and is then unpacked: for a 10 GB PST, have a good 20 GB free for a while.
A large migration takes several hours, with Gmail several days because of its daily limit, and with Microsoft 365 Graph slows down with many requests; the progress is shown on the same page, even after a restart.
If someone loses their phone with the authenticator app¶
Employees turn on the sign-in with a code themselves under Settings → Password and security. If the phone is lost, the boss resets it for that employee under Settings → Company → Employees; after that the employee signs in with just the password again and sets up the code anew.
AI agents¶
Agents like Claude connect over MCP to https://<KONTEXT_DOMAIN>/mcp; there is nothing
to set up. Each person connects their agent themselves under Settings → AI agents (key)
or adds the address in Claude as a connector and signs in in the browser (OAuth). For this:
- Caddy hands
/mcpand the OAuth discovery (/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server) to Kontext, and the other/.well-knownpaths on to Stalwart (deploy/Caddyfile). - The Kontext server must be allowed out to the internet (HTTPS): Claude and other clients sign in with an address where their description lives (Client ID Metadata Document), and Kontext fetches it from there – public addresses only, without redirects.
- Whoever no longer wants an agent disconnects it in the settings; locking an employee disconnects all their agents.
The assistant in the chat (AI agents → Assistant in the chat) comes with a Claude API key: create one at console.anthropic.com and enter it in the Firmenverwaltung under Chat → Assistant in the chat, and pick the model (recommended: Claude Sonnet 5.5). Kontext stores the key encrypted and afterwards shows only its last four characters. Each person may ask ten questions a minute. The questions and what the assistant looks up go to Anthropic in the USA: this belongs in the privacy notice, with Anthropic's data processing agreement.
4. Encrypting the disk¶
Mails, chat, tasks and files are on the server's disk. Encrypted, it is
useless to someone who takes the server or a disk away: when setting up, encrypt the data disk
with LUKS (many VPS providers offer this as an option when creating the server, otherwise a second
disk with cryptsetup luksFormat and the Docker volumes on it). Do not keep the key on the
server; enter it on reboot or unlock over the network (Clevis/Tang).
Encrypting and compressing the storage¶
On top of that, Stalwart itself encrypts what it stores: mails, attachments and files with a
server key (AES-256-GCM), compressed with Zstandard first, which saves space and so backup
storage. The key is not in Stalwart's database but in the environment variable
STALWART_BLOB_KEY of the Stalwart container; an image of the disk or a backup on its own
therefore does not give the mails away.
-
Create a key and put it into
deploy/.envasSTALWART_BLOB_KEY=…(with Quadlet intodeploy/stalwart.env,chmod 600, and uncomment the lineEnvironmentFile=/opt/kontext/deploy/stalwart.envinstalwart.container):openssl rand -base64 32 -
Put the key into the password manager (Keep the credentials safe). Once it is set, it must never change and must not get lost: without it Stalwart can no longer read the mails, not even from the backup.
- Restart Stalwart (
docker compose -f deploy/compose.yaml up -d, with Quadletsystemctl daemon-reload && systemctl restart stalwart) and run the setup command.
The command creates the key Kontext in Stalwart (a SymmetricKey that reads its secret from the
variable), switches mails (Email) and files (FileStorage) to Zstandard and this key, reloads
the settings and starts the task BlobRecode, which rewrites everything already stored in the
background. Depending on the amount that takes minutes to hours; the mails stay readable
meanwhile. On every later run it sees that everything is there and does nothing.
If the variable is missing, Stalwart refuses the key: the command then only compresses and says
so with a hint. If the admin has already chosen a key of their own, it stays. If Stalwart was
updated from a version before 1.0.3, its admin role may lack the permissions for keys
(sysSymmetricKey…) and for the rewrite (taskBlobRecode): the command still finishes and says so
with a hint; start Stalwart once with STALWART_UPDATE_ROLE_PERMISSIONS=true
(Stalwart 1.0.3) and run the command again; if the rewrite did not start, with
--recode, which starts it even without changed settings. What exactly the command does is in
server/stalwart-storage.ts.
5. Backups¶
deploy/backup.sh backs up with Restic to S3 storage, most simply
Cloudflare R2 (S3-compatible, 10 GB free) – Restic
encrypts every backup with the password from deploy/.env, so the storage provider only sees
encrypted data; keep the password in a safe place outside the server as well. What is backed up: Stalwart's
data volume (mails, calendars, contacts, accounts), a copy of the Kontext database
(sign-ins, company signature, tasks, chat, call list, state of the migrations), the photos and files from the
chat, announcements, voicemail messages, faxes and recordings from telephony, and the confidentially sent
mails, and with Documents also Schreibzeug's database (a copy) and files. Uploaded PST files are left out. Ideally use a bucket with Object Lock; then even an attacker with
access to the server cannot delete the backups. This does not replace a GoBD archive for mails. Set the retention period
in the bucket to 14 days at most: backup.sh keeps 14 days, 8 weeks and 12 months and clears away the
rest; files locked for longer cannot be deleted. The script reports that, but checks the
backup anyway.
The target is not on the server itself: if the server fails or is taken over, a backup next to it would be gone too, and it would fill the disk on which Stalwart accepts mail. Suitable:
| Target | RESTIC_REPOSITORY |
Cost |
|---|---|---|
| Cloudflare R2 (S3) | s3:https://<konto-id>.r2.cloudflarestorage.com/kontext-firma |
10 GB free, then about 1.5 cents per GB per month |
| Backblaze B2 (S3) | s3:https://s3.eu-central-003.backblazeb2.com/kontext-firma |
10 GB free |
| other S3 storage | s3:https://…/kontext-firma |
depends on the provider |
| NAS or storage box over SFTP | sftp:backup@nas.firma.de:/kontext |
your own hardware or Storage Box |
Restic compresses and stores identical data only once; the first backup is as large as the
data, each further one only as large as what is new. For S3, set AWS_ACCESS_KEY_ID and
AWS_SECRET_ACCESS_KEY (for R2, an API token with Object Read & Write for that one bucket); over
SFTP, root signs in with an SSH key (ssh-keygen, put the public part on the target,
ssh backup@nas.firma.de once to confirm the host key).
With R2, create the bucket with the location EU; the endpoint is then
https://<konto-id>.eu.r2.cloudflarestorage.com (the one without .eu does not find EU buckets). An
IP filter on the token needs the server's IPv4 address and its IPv6 network: Restic
prefers IPv6, otherwise R2 answers with Access Denied.
After every backup, backup.sh measures the size and from 8 GB on writes a warning to the log
(BACKUP_WARN_GB in the settings changes the limit) – before the free 10 GB run out. With R2,
also set up a mail from about 8 GB under Notifications → Usage Based Billing; it arrives
even when the server stays silent.
Restic must be installed on the server (apt-get install restic).
Every night, systemd units start the backup (templates in deploy/systemd/). With them,
the company admin shows the last run and the storage used under Backup, sets the
time and backs up at the push of a button. Kontext gets no access to the server for this: it
only leaves requests in its volume, which the units pick up
(Architecture).
cd /opt/kontext/deploy
./backup-control.sh install # units to /etc/systemd/system, volume path filled in
systemctl enable --now kontext-backup.timer kontext-backup-control.path
crontab -l | grep -v 'deploy/backup.sh' | crontab - # old cron entry, if there is one
systemctl start kontext-backup.service # first backup, log: journalctl -u kontext-backup
The units:
| Unit | Job |
|---|---|
kontext-backup.service |
calls backup.sh (which locks with flock: never two runs at once, not even by hand or via cron), output to the journal and to Kontext, status at the start and at the end |
kontext-backup.timer |
every night at 03:00 (Europe/Berlin), catches up on a missed night (Persistent=true) |
kontext-backup-control.path |
watches the time and "Back up now" from Kontext and starts kontext-backup-control.service |
install finds the volume kontext-data (Quadlet) or kontext_kontext-data (Compose) through
podman volume inspect or docker volume inspect. The time from Kontext ends up in
/etc/systemd/system/kontext-backup.timer.d/time.conf; if it is moved forward to a time that has
already passed today, the timer backs up once right away (Persistent=true). After a git pull
with changed units, run ./backup-control.sh install again.
Without the units, cron still works; the company admin then shows "Control isn’t set up on the server":
crontab -e
0 3 * * * /opt/kontext/deploy/backup.sh >> /var/log/kontext-backup.log 2>&1
With Podman (Quadlet) there is no deploy/.env; the script recognizes this by
/etc/containers/systemd/kontext.container and reads its settings from
deploy/backup.env (chmod 600):
KONTEXT_DOMAIN=kontext.firma.de
RESTIC_REPOSITORY=s3:https://s3.eu-central-1.example.com/kontext-firma
RESTIC_PASSWORD=…
AWS_ACCESS_KEY_ID=…
AWS_SECRET_ACCESS_KEY=…
The mail server pauses for a few seconds for this; sending servers simply try
again. The Kontext database is copied while running (VACUUM INTO). Once a month,
practise a restore on a test server, as described below.
Restoring¶
Restic creates three backups per run, recognizable by their tag: stalwart (Stalwart's data volume),
kontext (the copy of the database under backup/kontext.db and the folders chat-files,
photos, phone, secure) and, with Documents, schreibzeug (backup/schreibzeug.db, blobs,
auth-secret). The volumes are backed up under their path on the server, for example
/var/lib/docker/volumes/kontext_stalwart-data/_data (Compose with Docker) or
/var/lib/containers/storage/volumes/stalwart-data/_data (Quadlet). With
Compose the volumes are called kontext_stalwart-data, kontext_kontext-data, kontext_schreibzeug-data, with Quadlet
without the prefix stalwart-data, kontext-data, schreibzeug-data.
Not in the backup: the settings (deploy/.env or the Quadlet files and
kontext.env … – which is why they belong in the password manager, see
Saving the credentials) and Stalwart's volume stalwart-config.
That includes the storage key STALWART_BLOB_KEY: without the same
key the new Stalwart cannot read the restored mails.
It only holds config.json with the storage location; everything else (domains, accounts, DKIM keys,
settings) is in Stalwart's database and comes back with stalwart.
1. Provide a server. On a new server, install and start Kontext as in steps 1 and 2 with
the same settings, then go through Stalwart's initial setup
(user admin, password from the log), with the default storage (RocksDB under
/var/lib/stalwart/) – only then does config.json exist. Whatever you enter there is replaced
by the backup in a moment. On the old server, this step is not needed.
2. Give Restic the settings – the same ones backup.sh backs up with, as root:
apt-get install -y restic
cd /opt/kontext/deploy
set -a; . ./.env; set +a # with Quadlet: . ./backup.env
restic snapshots # all backups with tag, time and path
3. Stop the services, so that nothing writes to the volumes:
docker compose -f compose.yaml stop # Compose
systemctl stop caddy schreibzeug kontext stalwart # Quadlet
4. Restore into an empty folder, then into the volumes. vol gives the path of a
volume; with Podman Compose, podman instead of docker:
vol() { docker volume inspect -f '{{ .Mountpoint }}' "kontext_$1"; } # Compose
vol() { podman volume inspect -f '{{ .Mountpoint }}' "$1"; } # Quadlet
restic restore latest --tag stalwart --target /tmp/restore
restic restore latest --tag kontext --target /tmp/restore
restic restore latest --tag schreibzeug --target /tmp/restore # only with Documents
S=$(vol stalwart-data)
find "$S" -mindepth 1 -delete && cp -a "/tmp/restore$S/." "$S/"
K=$(vol kontext-data)
rm -f "$K/kontext.db" "$K/kontext.db-wal" "$K/kontext.db-shm"
cp -a "/tmp/restore$K/backup/kontext.db" "$K/kontext.db"
for d in chat-files photos phone secure; do rm -rf "${K:?}/$d"; cp -a "/tmp/restore$K/$d" "$K/"; done
D=$(vol schreibzeug-data) # only with Documents
rm -f "$D/schreibzeug.db" "$D/schreibzeug.db-wal" "$D/schreibzeug.db-shm"
cp -a "/tmp/restore$D/backup/schreibzeug.db" "$D/schreibzeug.db"
rm -rf "${D:?}/blobs" "$D/auth-secret"
cp -a "/tmp/restore$D/blobs" "/tmp/restore$D/auth-secret" "$D/"
The paths in the backup are those of the old server. If the new one runs the same way (Compose with Docker
or Quadlet), they are the same; otherwise restic snapshots shows them in the column Paths, and instead of
/tmp/restore$S it is then /tmp/restore<old path>. Restic restores owners and permissions
too, and cp -a keeps them.
With Quadlet, in steps 3 and 5 name only the units that exist on the server (without Documents
no schreibzeug, without calls no redis, livekit, livekit-sip).
5. Start and check:
docker compose -f compose.yaml up -d # Compose
systemctl start stalwart redis livekit livekit-sip kontext schreibzeug caddy # Quadlet
Then sign in to Kontext: mails, appointments, tasks and chat are back. If the IP addresses change on
a new server, update the DNS records (A, PTR). Delete /tmp/restore at the
end.
If the copy of the Kontext database is missing, nothing in Stalwart is lost: everyone signs in once more, and the template of the company signature has to be entered again; tasks and chat are then empty.
6. Updates¶
What changed in each version, and which versions need an extra step, is in the
changelog. Make a backup first
(systemctl start kontext-backup.service or "Back up now" in the company admin).
With Compose:
cd /opt/kontext && git pull
git diff ORIG_HEAD -- deploy/ # what changed in deploy/: .env.example, compose.yaml, Caddyfile, units
nano deploy/.env # KONTEXT_VERSION to the new version, e.g. 0.1.6; take over new lines from .env.example
docker compose -f deploy/compose.yaml pull && docker compose -f deploy/compose.yaml up -d
deploy/.envis your own copy of.env.exampleand does not change withgit pull. New settings are shown bygit diff ORIG_HEAD -- deploy/.env.example(or by comparing the two files); take over into.envwhat is new there.KONTEXT_VERSIONraises Kontext; the current version is in.env.exampleand under Releases.SCHREIBZEUG_VERSIONraises Schreibzeug, which has versions of its own (Schreibzeug's releases); if the line is missing,compose.yamltakeslatest.STALWART_VERSIONraises Stalwart, also fixed and by hand:git pullnever changes the mail server on its own. Raise it to the version in.env.exampletogether withKONTEXT_VERSIONof the same Kontext release, after the steps the changelog names for it (like the scope for Stalwart 1.0.3).- LiveKit, LiveKit SIP, Redis and Caddy have fixed versions in
deploy/compose.yaml; new ones come withgit pull, andpullfetches them. - If
deploy/Caddyfilehas changed, reload Caddy:docker compose -f deploy/compose.yaml restart caddy. - If the units in
deploy/systemd/have changed, run./backup-control.sh installindeploy/again.
With Quadlet: the files under /etc/containers/systemd/ are copies from deploy/quadlet/
with your own domain (Setting up a VPS, step 9); git
pull does not change them.
cd /opt/kontext && git pull
git diff ORIG_HEAD -- deploy/ # new image versions, settings, units
cd /etc/containers/systemd
nano kontext.container schreibzeug.container # Image=…/kontext:<new version>, …/schreibzeug:<new version>
systemctl daemon-reload
for i in $(grep -h '^Image=' *.container | cut -d= -f2); do podman pull $i; done
systemctl restart kontext schreibzeug # and every service whose image has changed
If more has changed in deploy/quadlet/ than the version (say, a new Environment= line),
take that over into the copy too; deploy/Caddyfile is included directly, then
systemctl restart caddy.
The images do not update themselves (not with Podman auto-update either): a new one only comes
when you raise the version. Exceptions that stay on latest until you enter a version:
Schreibzeug with Compose without SCHREIBZEUG_VERSION, and the phone box (see
Telephony).
Since 0.1.3 the Kontext server no longer runs as root, but as the user bun. A volume from
an older version still belongs to root; hand it over once, then start:
docker compose -f deploy/compose.yaml run --rm --user root --entrypoint chown kontext -R bun:bun /data
docker compose -f deploy/compose.yaml up -d
Before a Stalwart update to a new major version, read Stalwart's UPGRADING notes and
make a backup first. After every update, run the setup command again; it
only adds what a new version needs and is still missing.
Stalwart 1.0.3¶
Stalwart 1.0.3 no longer knows first-party clients: a registered client only gets
the scopes it requests and that it allows. For this, Kontext requests the
scope urn:stalwart:oauth:scope:full from this version on. So the client kontext needs the scope
Full, otherwise Stalwart rejects every new sign-in. The setup command
adds it: run it once after updating Kontext and Stalwart. By hand it goes under Management →
Directory → OAuth Clients (as a superuser) or with the helper from
Setting up a VPS, step 10:
/root/stalwart-api.sh '[["x:OAuthClient/get",{"ids":null,"properties":["clientId","scopes"]},"0"]]'
/root/stalwart-api.sh '[["x:OAuthClient/set",{"update":{"<id von kontext>":{"scopes/full":true}}},"0"]]'
Kontext and Stalwart go together in this update, with Compose KONTEXT_VERSION and
STALWART_VERSION=1.0.3 in deploy/.env raised together: Stalwart 1.0.3
with a Kontext up to 0.1.22 lets nobody sign in anew, because the old Kontext does not request the scope.
Everyone signs in once more: Stalwart discards grants from the time as a
first-party client as soon as Kontext renews the token (invalid_grant,
crates/http/src/auth/oauth/token.rs); Kontext then ends the session and shows the
sign-in. Started once with the
environment variable STALWART_UPDATE_ROLE_PERMISSIONS=true, Stalwart adds to the
default roles the permissions that have come along since 1.0.0 (such as encryption in the
admin interface); remove the variable again afterwards. Kontext only needs this for the
storage key, if the setup command points to it.
Upgrading Stalwart 0.16 to 1.0¶
Kontext from this version on needs Stalwart 1.0. Stalwart 1.0 stores its data differently and
does not open the 0.16 database (The data store was created by a Stalwart version prior to
1.0 and cannot be opened by this version): a git pull with up -d alone leaves Stalwart unable
to start, but no data is lost in the process. Stalwart's
migration tool stalwart-migrate copies the data into
a new, empty database; it only reads the old one. In detail:
Stalwart's guide.
Use stalwart-migrate from version 1.0.4 on: 1.0.0 to 1.0.3 abort run --final during the
check as soon as there are contacts (contactCard … differs in the target: vcard, for every vCard
whose N has empty fields at the end; that is how 0.16 stores them). The old database stays
untouched in that case; starting Stalwart 0.16 again is enough.
- Make a backup (Backups) and check that Stalwart 0.16.27 is running (raise earlier 0.16 versions to 0.16.27 first). Kontext and Stalwart stay on the old version for now.
- With the Docker image
stalwartlabs/stalwart-migrate:1.0.4(Stalwart's network, volumes and environment, new volumes for the 1.0 data and the state of the migration), runpreparefirst. It asks about what cannot be translated by itself – among other things the client address behind Caddy: X-Forwarded-For with one proxy, as in step 5. - Stop Stalwart, run
run --finaland read the report. - Put the new data volume in Stalwart's place,
git pull(Kontext and Stalwart 1.0.1), start. - In Stalwart 1.0, register Kontext as an OAuth client and allow directory queries (steps 12 and 13).
- Everyone signs in once more: Stalwart's tokens do not move along, and Kontext withdraws the
rejected sessions. Mail programs with a password keep working; CalDAV and CardDAV
reload calendars and contacts once, and Stalwart rebuilds the full-text search in the background.
Kontext up to 0.1.16 only passes the rejection on: the app keeps trying with the old token
again and again until Stalwart blocks the person's address for a day (the app only shows
"Something went wrong", see Troubleshooting). In that case, delete the
old tokens before starting Kontext:
… exec kontext bun -e 'import {Database} from "bun:sqlite"; new Database("/data/kontext.db").run("delete from stalwart_grant")'(docker compose -f deploy/compose.yamlorpodmanfor…).
With Quadlet, on one server it looked like this (volumes stalwart-config and stalwart-data,
network kontext). Take the values of the two Environment= lines from stalwart.container:
mig() { podman run --rm --network kontext \
-e STALWART_HOSTNAME=mail.firma.de -e STALWART_PUBLIC_URL=https://kontext.firma.de \
-v stalwart-config:/etc/stalwart:ro -v stalwart-data:/var/lib/stalwart \
-v stalwart-v1-config:/etc/stalwart-v1 -v stalwart-v1-data:/var/lib/stalwart-v1 \
-v stalwart-migrate-state:/var/lib/stalwart-migrate \
docker.io/stalwartlabs/stalwart-migrate:1.0.4 "$@" \
--source /etc/stalwart/config.json --state /var/lib/stalwart-migrate; }
V=/var/lib/containers/storage/volumes
for v in stalwart-v1-config stalwart-v1-data stalwart-migrate-state; do podman volume create $v; done
# In the 1.0 container, the new database is later under /var/lib/stalwart again
echo '{"@type":"RocksDb","path":"/var/lib/stalwart/"}' > $V/stalwart-v1-config/_data/config.json
chown -R 2000:2000 $V/stalwart-v1-config/_data $V/stalwart-v1-data/_data
mig prepare --non-interactive # writes answers.toml with the open questions
nano $V/stalwart-migrate-state/_data/answers.toml # uncomment the tables (recommendations)
T="--target /etc/stalwart-v1/config.json --target-path-map /var/lib/stalwart=/var/lib/stalwart-v1"
mig prepare --non-interactive # "Every question has an answer"
mig run $T && mig verify --full $T # trial run on the running server, without downtime
systemctl start kontext-backup
systemctl stop schreibzeug kontext stalwart
mig run --final --yes $T # must end with "verify: … 0 mismatches"
# Keep the 0.16 data (rollback), put the 1.0 data in its place
for v in config data; do
podman volume create stalwart-016-$v
cp -a $V/stalwart-$v/_data/. $V/stalwart-016-$v/_data/
rm -rf $V/stalwart-$v/_data/*
cp -a $V/stalwart-v1-$v/_data/. $V/stalwart-$v/_data/
done
Then raise the images in /etc/containers/systemd/ as under Updates, systemctl
daemon-reload, systemctl start stalwart, steps 5 and 6 above, then start Kontext and
Schreibzeug. stalwart-v1-* are no longer needed after that; stalwart-016-* and
stalwart-migrate-state (for rollback --forward-state) stay until 1.0 has been running for a while.
Only delete the old volume once 1.0 has been running for a while: stalwart-migrate rollback copies the
data back to 0.16 if need be.
Kontext and Schreibzeug have separate images and versions (KONTEXT_VERSION,
SCHREIBZEUG_VERSION); on start, Kontext only checks whether Schreibzeug answers, and otherwise writes
a warning to the log.
Settings at a glance¶
deploy/.env (Compose)¶
compose.yaml reads this file and passes on to each service only what it needs;
backup.sh reads all of it. With Quadlet it does not exist: there the values are in the
.container files and in kontext.env, livekit.env …, the backup values in backup.env.
| Variable | Default | What for |
|---|---|---|
KONTEXT_DOMAIN |
– | Kontext's address in the browser; Caddy gets the certificate, Kontext gets https://<…> as BETTER_AUTH_URL, Stalwart as its public address |
STALWART_HOSTNAME |
– | name of the mail server (MX, reverse DNS) |
MAIL_DOMAIN |
empty | the mail domain: Caddy serves mta-sts.<…> (MTA-STS) and openpgpkey.<…> (WKD) |
ACME_EMAIL |
– | address for Let's Encrypt |
TAILSCALE_IP |
127.0.0.1 |
where Stalwart's admin interface (port 8080) listens; with Tailscale, the server's Tailscale address |
KONTEXT_SECRET |
– | required, at least 32 characters; becomes BETTER_AUTH_SECRET |
STALWART_ADMIN_KEY |
empty | API key for the company admin |
STALWART_BLOB_KEY |
empty | key Stalwart encrypts mails and files with (openssl rand -base64 32); never change it once set (Encrypting the storage) |
KONTEXT_VERSION |
– | required: version of the image tristanteu/kontext |
STALWART_VERSION |
– | required: version of the image stalwartlabs/stalwart (without "v"), raised with KONTEXT_VERSION |
COMPOSE_PROFILES |
– | calls (LiveKit, LiveKit SIP, Redis) and documents (Schreibzeug) |
LIVEKIT_SECRET |
empty | secret for LiveKit and LiveKit SIP; empty: no calls |
SCHREIBZEUG_URL |
empty | address of Documents; empty: no module |
SCHREIBZEUG_CLIENT_SECRET |
empty | secret with which Schreibzeug signs in to Kontext |
SCHREIBZEUG_VERSION |
latest |
version of the image tristanteu/schreibzeug |
PLACES_URL |
komoot's Photon | location suggestions; empty: off (Location suggestions) |
RESTIC_REPOSITORY, RESTIC_PASSWORD |
– | target and password of the backups |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
– | keys for an S3 target |
BACKUP_WARN_GB |
8 |
from this backup size on, backup.sh writes a warning |
The Kontext server¶
What the server itself reads (server/env.ts). Of these, compose.yaml sets only the lines under
kontext: environment:; with Quadlet they are in kontext.container and kontext.env. The
image sets PORT=3000, STATIC_DIR=/app/dist, DATABASE_PATH=/data/kontext.db and
NODE_ENV=production. The defaults in the table are those without the image (development).
| Variable | Default | What for |
|---|---|---|
PORT |
18090 |
the server's port (3000 in the image) |
STALWART_URL |
http://127.0.0.1:18080 |
Stalwart's HTTP, directly (in Compose http://stalwart:8080) |
BETTER_AUTH_URL |
http://localhost:5173 |
Kontext's public address |
BETTER_AUTH_SECRET |
– | required, at least 32 characters, no change-me: signs the sessions, encrypts the Stalwart tokens |
DATABASE_PATH |
.dev/server/kontext.db |
the SQLite database (/data/kontext.db in the image) |
SIGN_IN_PER_MINUTE |
10 |
sign-ins per minute and IP address |
STALWART_ADMIN_KEY |
empty | API key (API_…) or name:passwort for the company admin |
DOH_URL |
https://cloudflare-dns.com/dns-query |
DNS over HTTPS (JSON API) for "Email security" and the search for S/MIME certificates |
SMIME_ROOTS |
empty | path to a PEM file with further recognized S/MIME authorities, such as the public administration's PKI; easier in the company admin under "S/MIME authorities" |
LIVEKIT_URL |
empty | LiveKit's API as the server reaches it (in Compose http://livekit:7880) |
LIVEKIT_API_KEY |
empty | LiveKit key (in Compose kontext) |
LIVEKIT_API_SECRET |
empty | LiveKit secret, at least 32 characters; without these three, no calls |
LIVEKIT_PUBLIC_URL |
empty | where browsers reach LiveKit, if not through Kontext's address (/rtc through Caddy) |
PHONE_SIP_HOST |
host of BETTER_AUTH_URL |
where the phone boxes reach LiveKit SIP |
PHONE_SIP_PORT |
5060 |
SIP port (TCP) for that |
SCHREIBZEUG_URL |
empty | Schreibzeug's address; with it, there is "Documents" |
SCHREIBZEUG_CLIENT_ID |
schreibzeug |
Schreibzeug's client ID (its OIDC_CLIENT_ID) |
SCHREIBZEUG_CLIENT_SECRET |
empty | its OIDC_CLIENT_SECRET, at least 32 characters |
PLACES_URL |
https://photon.komoot.io/api/ |
Photon for location suggestions; empty: off |
STATIC_DIR |
empty | the built app; when set, the server serves it as well |
NODE_ENV |
– | production in the image; otherwise the server uses development values (a fixed secret, admin and LiveKit from dev/config.ts) |
For tests only: MAIL_SYNC_ALLOW_PLAIN, UNSUBSCRIBE_ANY_TARGET, CALENDAR_SUBSCRIBE_ANY_TARGET, PUSH_ANY_ENDPOINT,
SNOOZE_TICK_MS, REMINDER_TICK_MS, RULE_TASK_TICK_MS, M365_LOGIN_URL and GRAPH_URL
(see Development).
Variables that compose.yaml does not pass on (DOH_URL, SMIME_ROOTS,
LIVEKIT_PUBLIC_URL, PHONE_SIP_HOST, PHONE_SIP_PORT, SIGN_IN_PER_MINUTE): entered in .env,
they do not arrive in the container. With Compose they belong as a line under
kontext: environment: in deploy/compose.yaml – a local change; if a git pull runs into
the same spot, git stash before and git stash pop after. A PEM file for SMIME_ROOTS must
also get into the container:
kontext:
environment:
# … the existing lines
SMIME_ROOTS: /etc/kontext/smime-roots.pem
DOH_URL: https://<eigener DoH-Dienst mit JSON-API>/dns-query
volumes:
- kontext-data:/data
- ./smime-roots.pem:/etc/kontext/smime-roots.pem:ro
The file then sits next to it as deploy/smime-roots.pem and must be readable for the user bun in the
container (chmod 644). With Quadlet, the variables go into kontext.env and the
file into kontext.container with Volume=/opt/kontext/deploy/smime-roots.pem:/etc/kontext/smime-roots.pem:ro;
then systemctl daemon-reload and systemctl restart kontext.
Troubleshooting¶
The logs: docker compose -f deploy/compose.yaml logs <dienst> (Compose) or
podman logs <dienst> (Quadlet), for the backup journalctl -u kontext-backup.
- Nobody can sign in any more, even with the right password: Stalwart has blocked Kontext's address.
Kontext signs in everyone from one address; without the entry
10.89.250.0/24among the allowed IPs (Setting up Stalwart, step 6), Stalwart blocks it after 100 failed attempts a day. Add the entry and delete the ban: Stalwart keeps it asBlockedIp(through the APIx:BlockedIp/get, thenx:BlockedIp/setwithdestroy; helper in Setting up a VPS, step 10). With a ban duration (step 7) it expires by itself. If Kontext's log saysStalwart lehnt die Anmeldung für Kontext ab("Stalwart rejects the sign-in for Kontext"), the OAuth clientkontextor its scopefullis missing (step 12, Stalwart 1.0.3); ifinvalid_scopeis next to it, it is the scope. - For one person the app only shows "Something went wrong": Stalwart has blocked this
person's address (
Banned due to authentication errors … remoteIp = <their address>in Stalwart's log), for example because Kontext up to 0.1.16 kept sending old tokens after the move to Stalwart 1.0. Delete the ban withx:BlockedIp/setanddestroyand then trigger the actionReloadBlockedIps(x:Action/setwith{"@type":"ReloadBlockedIps"}). Without it, Stalwart 1.0 keeps the ban in memory and keeps answering with 403, even though it is no longer in the list. - No certificate, the browser warns: Caddy gets the certificates on start
(
logs caddy). The A records must point to the server beforehand and, at Cloudflare, must not go through the proxy (see Server and DNS); ports 80 and 443 must be open. If the server asked before the records existed, its resolver remembers that for a while (Setting up a VPS, step 7). - Mails do not arrive or land in spam: port 25 unblocked at the provider, reverse DNS set (Setting up a VPS, step 5), SPF, DKIM and DMARC set; the company admin checks under "Email security" what is missing. Blocklists: Setting up a VPS, step 12.
- "Schreibzeug antwortet nicht" ("Schreibzeug does not answer") in Kontext's log: harmless on the first start if
Schreibzeug did not find Kontext yet (Setting up a VPS, step 9).
If it stays, check whether
SCHREIBZEUG_URLis in the DNS (getent hosts dokumente.firma.deon the server) and the service is running (logs schreibzeug). - New mails only appear after reloading: push needs a connection that a proxy
does not buffer. It does not get through the Cloudflare proxy (see Server and DNS);
a proxy of your own in front of Caddy must pass
/api/liveand/jmap/eventsourcethrough unbuffered and uncompressed, asdeploy/Caddyfileitself does. - Calls without sound or picture: TCP 7881 and UDP 7882 open, behind NAT set
node_ip(Calls). - The phone box does not register with the Fritzbox: see Telephony, "If the box does not register with the Fritzbox"; a box before v0.1.4 cannot do it.
- "Control isn’t set up on the server" under Backup: the systemd units are not installed or not running (Backups).
- Other mailboxes are missing from the settings, invitations have no buttons:
STALWART_ADMIN_KEYis missing (Turning on the company admin). If only the company admin is missing, your own account in Stalwart does not have the roleFirmenverwaltung (Kontext)yet (step 4 there).