This document describes how to run marchat-server on the host (plain HTTP on port 8080), put Caddy in Docker in front of it (TLS on host port 8443), and connect the client with wss://. It also lists source changes in this repo that make that setup reliable.
Commands are given in two forms where it matters: Unix shell (bash) and Windows PowerShell.
marchat-client --wss--> localhost:8443 (Docker publishes host 8443 -> container 443)
|
Caddy (TLS terminate, WebSocket-aware reverse_proxy)
|
host.docker.internal:8080
|
marchat-server (host process, reads config/.env)
- Do not expose 8080 to the internet if you only intend to serve through Caddy; forward 8443 on the router instead.
- Caddy reaches the server via
host.docker.internal(seedocker-compose.proxy.yml). Docker Compose addshost-gatewayon Linux, Windows, and macOS so this matches a typical dev setup.
- Go 1.25.9+ (for building from source).
- Docker with Compose v2 (Docker Engine on Linux; Docker Desktop on Windows or macOS is fine).
- marchat repo cloned; server config directory
config/exists.
Create config/.env as UTF-8 without a BOM (a BOM breaks parsing and you will see errors like unexpected character near the first variable).
Minimum:
MARCHAT_PORT=8080
MARCHAT_ADMIN_KEY=<64-char-hex-or-your-secret>
MARCHAT_USERS=admin1Optional (global E2E, same value on every client that should read encrypted channels):
MARCHAT_GLOBAL_E2E_KEY=<base64-32-bytes>Generate examples:
- Admin key: any cryptographically strong secret (often 64 hex chars).
- Global E2E key: 32 random bytes in base64, e.g.
openssl rand -base64 32(Unix) or WSL/Git Bash on Windows.
Release-style local build:
CGO_ENABLED=0(static binary, same as Docker/release; uses pure-Go SQLite).-ldflagssetshared.ClientVersion,shared.ServerVersion,shared.BuildTime,shared.GitCommit.
Unix (Linux binary on Linux; set GOOS / GOARCH for cross-compile):
chmod +x scripts/build-linux.sh scripts/connect-local-wss.sh # once, if needed
./scripts/build-linux.sh
# macOS host targeting macOS:
# GOOS=darwin GOARCH=arm64 ./scripts/build-linux.sh
# (Script name is historical; it honors GOOS/GOARCH.)Windows (amd64 .exe in repo root):
.\scripts\build-windows.ps1Or mirror the same flags from build-release.ps1 / .github/workflows/release.yml. The release workflow sets CGO_ENABLED=0 on the build job (static cross-compiles, pure-Go SQLite) and uses a resolve-version job so the tag/input version is available to Docker and matrix builds (matrix jobs cannot publish job outputs).
From the repository root (so default config dir resolves to ./config and loads config/.env):
Unix:
./marchat-serverWindows:
.\marchat-server.exeConfirm:
Unix:
curl -fsS http://127.0.0.1:8080/health/simple
# Expect: OKWindows:
Invoke-WebRequest http://127.0.0.1:8080/health/simple -UseBasicParsing
# Expect: OKRestart the server after editing config/.env.
docker-compose.proxy.yml– runscaddy:2-alpine, maps8443:443, mountsdeploy/caddy/Caddyfile, loadsdeploy/caddy/proxy.env.exampleand merges optionaldeploy/caddy/proxy.env(gitignored), addshost.docker.internal:host-gateway.deploy/caddy/Caddyfile– TLS +reverse_proxytohost.docker.internal:8080; optional extra TLS names viaMARCHAT_CADDY_EXTRA_HOSTS(seeproxy.env.example/ localproxy.env).deploy/caddy/proxy.env.example– tracked defaults (emptyMARCHAT_CADDY_EXTRA_HOSTS). Copy todeploy/caddy/proxy.env(ignored by git) to set public IP or DNS names on the site line sotls internalcertificates match how remote clients connect.
-
Named site block, not bare
:443A site like
:443 { ... }withtls internalcaused TLS alert internal_error (clients sawremote error: tls: internal error). Use explicit names:localhost, 127.0.0.1{$MARCHAT_CADDY_EXTRA_HOSTS} { tls internal reverse_proxy host.docker.internal:8080 { flush_interval -1 header_up X-Forwarded-Proto https } }
{$MARCHAT_CADDY_EXTRA_HOSTS}comes from Composeenv_file(proxy.env.example, then optionalproxy.env). For internet clients dialing your public IP, copy the example todeploy/caddy/proxy.envand set e.g.MARCHAT_CADDY_EXTRA_HOSTS=, 203.0.113.5(leading comma + space), then recreate Caddy (see below). -
flush_interval -1Recommended for long-lived WebSocket streams so Caddy does not buffer responses in a way that stalls the connection.
-
First-time or broken TLS state
If you previously used a bad
:443config, recreate Caddy’s volumes once so internal certs are re-issued:Unix:
docker compose -f docker-compose.proxy.yml down docker volume rm marchat_caddy_data marchat_caddy_config # ignore errors if missing docker compose -f docker-compose.proxy.yml up -dWindows (PowerShell):
docker compose -f docker-compose.proxy.yml down docker volume rm marchat_caddy_data marchat_caddy_config # ignore errors if missing docker compose -f docker-compose.proxy.yml up -d
Unix or Windows (same):
docker compose -f docker-compose.proxy.yml up -dAllow inbound TCP 8443 so LAN or internet clients can reach Caddy.
Windows (elevated PowerShell):
New-NetFirewallRule -DisplayName "marchat WSS (8443)" -Direction Inbound -LocalPort 8443 -Protocol TCP -Action AllowLinux (ufw):
sudo ufw allow 8443/tcp
sudo ufw reloadLinux (firewalld):
sudo firewall-cmd --permanent --add-port=8443/tcp
sudo firewall-cmd --reloadAdjust if you use another firewall or bind Docker differently.
- Prefer
wss://localhost:8443/wswhile using Caddy’stls internalcertificates. - Until you use a real public CA certificate on Caddy, use
--skip-tls-verifyon the client.
The Go client in this repo forces ALPN http/1.1 for wss and fixes SNI when the URL uses loopback IPs (see Source changes below).
- Set
MARCHAT_GLOBAL_E2E_KEYin the environment to match the server (or rely on a client-generated key only if you control all peers). --e2eand--keystore-passphraseunlock the local keystore; passphrase is not the admin key.
Unix:
./scripts/connect-local-wss.sh
# Non-interactive keystore pass:
# KEYSTORE_PASS='yourpass' ./scripts/connect-local-wss.shWindows:
.\scripts\connect-local-wss.ps1 -KeystorePass yourpassBoth read config/.env for admin key and global E2E key, run the client against wss://localhost:8443/ws with --skip-tls-verify. Username is MARCHAT_CLIENT_USERNAME if set, otherwise the first name in MARCHAT_USERS.
PowerShell scripts: use ASCII punctuation in strings (avoid Unicode dashes) so Windows PowerShell 5.1 does not mangle encoding.
If the shell or IDE exports a stale MARCHAT_*, the server used to keep the old value because godotenv.Load does not override existing env vars. This repo now uses godotenv.Overload for config/.env so the file wins (see config/config.go). Restart the server after changing code.
- Add every host or IP clients will use to
MARCHAT_CADDY_EXTRA_HOSTSin localdeploy/caddy/proxy.env(copy fromproxy.env.exampleif needed; see comments there), then recreate Caddy so TLS SANs match SNI. - LAN: clients use
wss://<your-LAN-IP>:8443/ws(firewall must allow 8443). - Internet: router port forward TCP external 8443 (or 443) to this PC 8443; clients use your public IP or DNS name. Prefer a real TLS certificate on Caddy for production and remove
--skip-tls-verify. - Optional: fill
deploy/REMOTE-INVITE.template.mdfor copy-paste guest instructions (do not commit secrets).
| Area | File | Change |
|---|---|---|
| Deploy | deploy/caddy/Caddyfile |
localhost, 127.0.0.1{$MARCHAT_CADDY_EXTRA_HOSTS} { ... } instead of :443, tls internal, reverse_proxy host.docker.internal:8080 with flush_interval -1 and X-Forwarded-Proto. |
| Deploy | deploy/caddy/proxy.env.example |
Tracked defaults for Caddy env; optional local deploy/caddy/proxy.env (gitignored) overrides MARCHAT_CADDY_EXTRA_HOSTS for public IP / DNS. |
| Deploy | docker-compose.proxy.yml |
Caddy service, 8443:443, env_file for proxy.env.example plus optional proxy.env, volume mount for Caddyfile, host.docker.internal:host-gateway, named volumes for Caddy data/config. |
| Client | client/websocket.go |
For wss:: copy DefaultDialer, set TLSClientConfig with NextProtos: []string{"http/1.1"}, InsecureSkipVerify when --skip-tls-verify, ServerName from URL host with loopback IPs mapped to localhost for SNI. Narrower detection of “username taken” errors on read (no broad Username substring). |
| Server config | config/config.go |
godotenv.Overload instead of Load for config/.env so file values override pre-set MARCHAT_* in the process environment. |
| Tests | config/config_test.go |
TestDotenvFileOverridesProcessEnv replaces old precedence test: .env must override conflicting process MARCHAT_PORT. |
| Scripts | scripts/build-linux.sh |
Local CGO_ENABLED=0 + ldflags build for marchat-server / marchat-client (defaults GOOS=linux; override for cross-compile). |
| Scripts | scripts/build-windows.ps1 |
Same flags for marchat-server.exe / marchat-client.exe on Windows amd64. |
| Scripts | scripts/connect-local-wss.sh |
Unix: loads config/.env, runs marchat-client to wss://localhost:8443/ws with E2E + admin + --skip-tls-verify. |
| Scripts | scripts/connect-local-wss.ps1 |
Windows: same behavior for marchat-client.exe. |
| Symptom | Where to look |
|---|---|
remote error: tls: internal error on connect |
Caddyfile must use named hosts + tls internal; recreate Caddy volumes if certs were issued under bad config; use wss://localhost:8443. |
| Reconnect loop / dial failures | Client debug log: Windows %APPDATA%\marchat\marchat-client-debug.log; Linux ~/.config/marchat/marchat-client-debug.log; macOS ~/Library/Application Support/marchat/marchat-client-debug.log (unless MARCHAT_CONFIG_DIR is set). |
| Invalid admin key | Server must use same key as client; with Overload, config/.env overrides stale shell env after server restart. |
| Keystore decrypt error | Wrong --keystore-passphrase, corrupted file, or (on older clients) path-dependent keystore salt if keystore.dat moved; use a current client build (embedded salt + auto-migration). If MARCHAT_GLOBAL_E2E_KEY is set, the client uses the env key and does not update the file. Unset it to use the on-disk key again. Backup/remove keystore.dat only if you intend to recreate the keystore (you will need the same global key via env or peer copy). |
| Caddy cannot reach server | Server on 8080, Docker host.docker.internal (Compose extra_hosts). |
These apply to the server and client behavior shipped with the Caddy / WSS work. The WebSocket wire protocol between client and server is unchanged.
Previous behavior: godotenv.Load did not replace variables that were already set in the process environment. If MARCHAT_ADMIN_KEY (or any other key) was present in the environment before the server read config/.env, the environment value won and the file was ignored for that key.
Current behavior: godotenv.Overload applies config/.env on top of the process environment. Any key present in config/.env overwrites the same MARCHAT_* name in the process at server startup.
| Who is affected | What to do |
|---|---|
Deployments that mount or ship a config/.env (or .env under MARCHAT_CONFIG_DIR) and inject overlapping MARCHAT_* via Docker/Kubernetes/systemd |
Treat the file as authoritative for those keys, or remove conflicting keys from the file if orchestrator secrets must win. |
| Operators who relied on “env always beats .env” | Align secrets: either stop shipping a conflicting .env or remove the env var and use only the file. |
Local dev / single config/.env as source of truth |
No change required; this matches the common expectation that editing the file updates the server after restart. |
Not a protocol break: clients do not need an upgrade solely because of this; only server config precedence changed.
Previous behavior: Any read error whose message contained the substring Username (capital U) was classified as a username conflict and did not trigger the reconnect loop.
Current behavior: Only messages matching case-insensitive phrases like username already taken, username is already taken, or duplicate username are treated as username errors.
| Who is affected | What to do |
|---|---|
Rare: a server or proxy closing the socket with a custom close reason that mentioned Username but was not a duplicate-user case |
That case may now be treated as a generic disconnect and reconnect instead of a static “pick another username” banner. |
docker-compose.proxy.yml,deploy/caddy/,scripts/connect-local-wss.{sh,ps1}, andscripts/build-{linux.sh,windows.ps1}are optional; existing directws:///wss://to marchat (no Caddy) flows are unchanged.CADDY-REVERSE-PROXY.mdis documentation only.
- Terminate TLS with Let's Encrypt (or your CA) on Caddy using a real DNS name; drop
--skip-tls-verifyon clients. - Set
MARCHAT_ALLOWED_USERSon the server to restrict usernames. - If you both mount
config/.envand injectMARCHAT_*secrets, see Breaking changes - file values override env for keys listed in the file.