Files
webmail-client/docs/nas-install.md
T
lukasandClaude Opus 5 cc63efa968 Docs: the real DSM fix is bridge-nf-call-iptables=0, not an iptables rule
Confirmed on the operator's NAS. The earlier advice (insert an ACCEPT rule
ahead of DSM's chain) does not hold: DSM reloads its firewall periodically,
flushing and rebuilding the chains. You can watch FORWARD_FIREWALL's DROP
counter reset to zero and the inserted rule vanish with it, having matched no
packets. That is also why the symptom looked intermittent — 60/40 success on
repeated connections, with RELATED,ESTABLISHED keeping existing flows alive.

The fix is one level down: net.bridge.bridge-nf-call-iptables=0 takes
same-bridge traffic out of iptables entirely, so DSM's reloading cannot affect
it. Persisted with a Boot-up Triggered Task.

Section 8 now also answers "is that safe", accurately: the flag affects only
packets bridged within one network. Traffic between different Docker networks
is routed, so DOCKER-ISOLATION still applies; inbound traffic to published
ports is still routed and still firewalled; INPUT, which protects the NAS
itself, is untouched. The one real caveat is that it is host-wide, so VMs
sharing a bridge lose same-bridge filtering too.

The migrate failure message points at the sysctl now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 21:21:22 +02:00

526 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Running Webmail on a Synology or QNAP NAS
This is the operator runbook: how to install it, how to keep your mail safe from
anything Docker does to itself, and how to update without holding your breath.
It assumes the NAS is doing other work too, so the stack is deliberately modest:
**one published port, no host networking, memory caps on every container.**
Everything here works on any Intel/AMD Docker host — the NAS parts are just the
specifics for DSM and QTS.
---
## 1. What gets installed
| Container | What it does | Published? |
|---|---|---|
| `web` | nginx: serves the UI, proxies `/api` (incl. the WebSocket) | **yes — the only one** |
| `api` | FastAPI app | no, internal network only |
| `worker-sync` | keeps IMAP connections open, syncs mail | no |
| `worker-jobs` | background jobs (send, backfill, purge, plugin jobs) | no |
| `postgres` | the database | no |
| `redis` | job queues + sync leases | no |
| `backup` | nightly verified database dump | no |
| `meilisearch` | optional better search (see §8) | no |
| `migrate`, `init` | run once at start, then exit | — |
Plugins (calendar, contacts, labels, filters) are baked into the backend image
and their database schema is applied automatically on every start.
**Requirements:** an Intel/AMD NAS with Docker (Synology *Container Manager*,
QNAP *Container Station*), ~2 GB RAM free, and 10 GB+ of disk for a typical
personal mailbox. Nothing is needed from the internet except your mail servers.
---
## 2. Install
### 2.1 Pick where the data lives
This is the single most important decision. Use a **shared folder**, never a
Docker named volume:
| NAS | Use |
|---|---|
| Synology | `/volume1/docker/webmail/data` |
| QNAP | `/share/Container/webmail/data` |
Why: named volumes live inside Docker's internal storage, where Hyper Backup,
Snapshot Replication and HBS3 cannot see them, and where "delete the project" in
the NAS UI can take your mail with it. A shared folder is backed up by the tools
you already run, survives every container operation, and can be copied to
another machine wholesale.
> **Never put this on an NFS or SMB mount.** Postgres on a network filesystem
> corrupts. `./deploy/webmail.sh doctor` checks this and refuses to be quiet
> about it.
### 2.2 Get the code onto the NAS
Enable SSH on the NAS (DSM: *Control Panel → Terminal & SNMP → Enable SSH*;
QTS: *Control Panel → Telnet/SSH*), then:
```bash
ssh admin@your-nas
sudo -i
mkdir -p /volume1/docker/webmail && cd /volume1/docker/webmail
git clone <your-gitea-url>/webmail.git src && cd src
```
No git on the NAS? Copy the folder over with File Station or `rsync` instead.
### 2.3 Configure
```bash
./deploy/webmail.sh init
```
This creates `.env` with a freshly generated `MASTER_KEY` and
database password. Then open it and set:
```ini
DATA_ROOT=/volume1/docker/webmail/data # or /share/Container/webmail/data
APP_DOMAIN=mail.home.lan # your handles become you@this
TZ=Europe/Warsaw
WEB_PORT=8088 # avoid 5000/5001 (DSM), 8080 (QTS)
WEB_BIND=0.0.0.0 # or one NAS IP, e.g. 192.168.1.185
```
> ### Copy `MASTER_KEY` into your password manager. Now.
> It encrypts every stored mail-account password. It exists **only** in this
> file. A perfect database backup without this key gives you your mail but not
> your credentials — you would have to re-enter every account password by hand.
> `.env` is `chmod 600` and git-ignored; treat it as a backup
> artefact in its own right.
### 2.4 Start
```bash
./deploy/webmail.sh doctor # sanity-checks config, disk, filesystem, ports
./deploy/webmail.sh up # builds the images on first run, then starts
```
First run builds both images. The frontend build wants ~2 GB of RAM: if your NAS
is small or you'd rather not tie it up, build on your PC instead — see §6.3.
Open `http://your-nas:8088/`, create your account, and add a mail account
through the wizard.
### 2.5 Installing entirely from the DSM GUI (no SSH)
The whole install can be done without ever enabling SSH. The trick is **Task
Scheduler**: it runs any shell command as root from the DSM web UI, which covers
the three things the Container Manager UI cannot do on its own.
**1. Get the code onto the NAS.** In Gitea, open the repository and use
*Download ZIP* (the `<>` Code button). In DSM, open *File Station*, create
`docker/webmail/`, upload the zip into it, then right-click → *Extract here*.
Rename the extracted folder to `src`, so you end up with
`/volume1/docker/webmail/src/docker-compose.yml`.
**2. Configure it — one Task Scheduler job.** *Control Panel → Task Scheduler →
Create → Scheduled Task → User-defined script*. Set **User: root**, leave it
unscheduled, and paste this as the script (adjust the four values):
```bash
cd /volume1/docker/webmail/src
chmod +x deploy/*.sh docker/*.sh
bash deploy/webmail.sh init \
--data-root /volume1/docker/webmail/data \
--domain mail.home.lan \
--tz Europe/Warsaw \
--port 8088
```
Save, select the task, and click *Run*. The `chmod` line matters: a zip download
loses the executable bit. This generates `MASTER_KEY`, a database password, and
the `.env` that Container Manager will read — with no hidden files to edit.
**3. Back up the master key — a second one-off task.** Same recipe, script:
```bash
cd /volume1/docker/webmail/src
bash deploy/webmail.sh show-key > /volume1/docker/webmail/MASTER_KEY.txt
```
Run it, open `MASTER_KEY.txt` in File Station, copy the key into your password
manager, then **delete the file**.
> Doing this from an SSH shell instead? `init` created that file as root with
> mode 600, so run it as root or you get "Permission denied":
> `sudo bash deploy/webmail.sh show-key`. The same goes for every other command
> here — the data directories are root-owned, so start with `sudo -i`. Skipping this is the one mistake with no
recovery: without that key, no backup can decrypt your saved mail-account
passwords.
**4. Create the project.** *Container Manager → Project → Create*:
| Field | Value |
|---|---|
| Project name | `webmail-prod` |
| Path | `/volume1/docker/webmail/src` |
| Source | it will find `docker-compose.yml` — choose to use the existing file |
Pick the **repo root**, not `docker/`. Both contain a `docker-compose.yml`, and
the one in `docker/` is the throwaway *dev* service stack — starting it gives
you containers but no webmail server. Use the project name `webmail-prod`
exactly: the name typed here overrides the one in the file, and matching them
means the GUI and `deploy/webmail.sh` drive the same stack.
Click through, and it builds. **First build takes 5-15 minutes and wants ~2 GB
of RAM** — it compiles the frontend on the NAS. This is the one place the GUI
route is meaningfully worse than SSH, where you can build on your PC and copy
the images over (§6.3).
**5. Open `http://your-nas:8088/`** and register your account.
**What you will see in Container Manager:** `init` and `migrate` sitting at
**Exited (0)**. That is correct — they are one-shot jobs that did their work and
stopped, and everything else waited on them.
#### Running the maintenance commands without a shell
Task Scheduler is not just for setup — it runs any of these, and can schedule
them. Each is a user-defined script, user **root**, starting with
`cd /volume1/docker/webmail/src`:
| Task | Script line | When |
|---|---|---|
| Update safely | `bash deploy/webmail.sh update` | after pulling new code |
| Roll back | `bash deploy/webmail.sh rollback` | if an update misbehaves |
| Backup now | `bash deploy/webmail.sh backup` | before anything risky |
| Restore | `echo RESTORE \| bash deploy/webmail.sh restore <file>` | disaster recovery |
| Health check | `bash deploy/webmail.sh doctor` | when something looks wrong |
| Rebuild search | `bash deploy/webmail.sh reindex --purge` | after a restore |
Updating *through the Container Manager UI instead* (project → Build) does work
and does apply migrations — but it takes no backup first, has no health gate,
and will not roll back if the new version fails to start. The `update` task
above does all three. Use it.
### 2.6 Starting automatically after a reboot
Every service is `restart: unless-stopped`, so Docker brings the stack back on
boot by itself. On Synology, make sure Container Manager itself is set to start
on boot (it is by default).
---
## 3. The three things that matter
Containers are disposable. These are not:
| # | What | Where | If you lose it |
|---|---|---|---|
| 1 | **Database** | `$DATA_ROOT/postgres` + nightly dumps in `$DATA_ROOT/backups` | Everything: accounts, threads, flags, calendar, contacts |
| 2 | **Blob store** | `$DATA_ROOT/blobs` | Mail bodies and attachments (re-downloadable from the servers, slowly) |
| 3 | **`MASTER_KEY`** | `.env` + your password manager | Every saved mail-account password, permanently |
Everything else is derived and needs no backup: Redis holds queues and leases,
Meilisearch's index rebuilds from Postgres (§8), and images rebuild from source.
### Backups
Two layers, and you want both:
**Nightly dumps (automatic).** The `backup` container writes a compressed
`pg_dump` to `$DATA_ROOT/backups` at `BACKUP_AT` (default 03:30) and keeps
`BACKUP_KEEP_DAYS` (14) of them. Every dump is verified with `pg_restore --list`
before it counts, so you never find out at restore time that it was truncated.
**Off-box copies (you set this up, once).** Point your NAS backup tool at
`$DATA_ROOT`:
- Synology: *Hyper Backup* → local/remote/cloud destination → include the
`docker/webmail` shared folder. Add *Snapshot Replication* on a Btrfs volume
for fast local rollback.
- QNAP: *HBS 3* → the `Container/webmail` folder. Add snapshots on QuTS hero.
A snapshot of a *running* Postgres directory is crash-consistent — it usually
replays fine, but it is not a substitute for the dumps, which are verified and
restorable into a different Postgres version. Keep both.
Also back up `.env` somewhere that is not the NAS.
### Restore drill — do this once, before you need it
```bash
./deploy/webmail.sh status # see available dumps
./deploy/webmail.sh restore webmail-20260914-033000.dump
```
It stops the app containers, restores in a single transaction (a failure leaves
the old database untouched), and starts everything back up.
Full disaster recovery onto a new box:
1. Install Docker, copy `$DATA_ROOT` back from your backup.
2. Copy the repo and `.env` — **with the original `MASTER_KEY`**.
3. `./deploy/webmail.sh up`
That's it. If the blob store was lost but the database survived, mail bodies
refill from the mail servers as accounts re-sync.
---
## 4. Updating
```bash
git pull
./deploy/webmail.sh update
```
`update` does, in this order, stopping the moment anything fails:
1. **Takes a verified database backup.** No backup, no update.
2. Builds (or pulls) the new images. A failure here changes nothing.
3. Runs migrations as a one-shot container — **before** any service restarts, so
a bad migration never leaves new code running against an old schema.
4. Rolls workers first, then API, then web. Sync leases transfer to the
replacement worker within ~90 s and message upserts are idempotent, so no
mail is lost or duplicated by a roll.
5. Health-gates for 120 s. **If the new version doesn't come up healthy it rolls
itself back automatically** and leaves you on the previous version.
To go back manually:
```bash
./deploy/webmail.sh rollback # previous version
./deploy/webmail.sh rollback v1.2.0 # a specific one
```
Rolling *code* back does not require rolling the *schema* back, as long as
migrations stay expand-then-contract (see `docs/migrations.md` discipline in
`deployment-setup-instructions.md` §9). For the rare breaking migration, restore
the dump that `update` took in step 1.
### Building images elsewhere
Building on the NAS is simple but slow and RAM-hungry. Two alternatives:
```bash
# On your PC — push to your Gitea registry:
REGISTRY_PREFIX=192.168.1.185:8418/claude/webmail ./deploy/build-push.sh v1.0.0
# then on the NAS, set REGISTRY_PREFIX + IMAGE_TAG in .env and:
./deploy/webmail.sh update v1.0.0
# Or with no registry at all — build to a file and carry it over:
./deploy/build-push.sh v1.0.0 --save
# copy webmail-v1.0.0.tar to the NAS, then:
docker load -i webmail-v1.0.0.tar && ./deploy/webmail.sh update v1.0.0
```
---
## 5. Reaching it from outside the LAN
### 5.1 Read this first
**Anyone who can open the page can create an account on it.** Registration is
open by design (it's a personal server), so do not put this straight on the
public internet. Pick one:
- **VPN** (Tailscale, WireGuard, or the NAS's own VPN Server) — simplest and
safest. The port stays LAN-only.
- **NAS reverse proxy with HTTPS**, restricted by firewall rules to the networks
you trust.
### 5.2 HTTPS through the NAS reverse proxy
DSM owns ports 80/443, which is why the stack publishes 8088. To put DSM's
certificate in front of it:
*Control Panel → Login Portal → Advanced → Reverse Proxy → Create*
| Field | Value |
|---|---|
| Source protocol / hostname / port | HTTPS / `mail.yourdomain` / 443 |
| Destination protocol / hostname / port | HTTP / `localhost` / `8088` |
Then open **Custom Header → Create → WebSocket** and save. Without that,
`/api/ws` is blocked and the UI stops updating live.
QNAP: *Network & Virtual Switch → Reverse Proxy* — same idea, and likewise
enable WebSocket support.
Session cookies get the `Secure` and `__Host-` treatment automatically as soon
as the app sees an HTTPS request: nginx forwards `X-Forwarded-Proto`, and the
API is started with `--proxy-headers`. Nothing to configure.
---
## 6. Resource usage
Steady state for one or two mail accounts, `SEARCH_BACKEND=pg`:
| | Typical | Cap (`.env`) |
|---|---|---|
| postgres | 150-300 MB | `PG_MEM_LIMIT=768m` |
| api | 150-250 MB | `API_MEM_LIMIT=512m` |
| worker-sync | 120-250 MB | `WORKER_SYNC_MEM_LIMIT=512m` |
| worker-jobs | 150-350 MB | `WORKER_JOBS_MEM_LIMIT=640m` |
| redis / web / backup | < 100 MB total | — |
| **total** | **~0.7-1.2 GB** | ~2.2 GB of limits |
| meilisearch (optional) | 300-800 MB | `MEILI_MEM_LIMIT=1g` |
On a 2 GB NAS: stay on `SEARCH_BACKEND=pg` and drop `PG_SHARED_BUFFERS` to
`128MB`. Tight but workable. On 4 GB+ you have room for Meilisearch.
Two NAS-specific realities worth knowing:
- **Disk hibernation won't happen.** Active accounts hold IMAP IDLE connections
open and write as mail arrives. That is the feature working, not a bug.
- **Keep NTP on.** Two-factor codes fail on a drifting clock.
---
## 7. Search: Postgres or Meilisearch
`SEARCH_BACKEND=pg` (default) uses Postgres full-text search. No extra
container, no extra RAM, and it is perfectly good for a personal mailbox.
`SEARCH_BACKEND=meili` adds typo tolerance, better ranking and stays fast on
large mailboxes, at the cost of a container and 300-800 MB.
To switch:
```bash
# in .env: SEARCH_BACKEND=meili and set MEILI_MASTER_KEY
./deploy/webmail.sh up # starts Meilisearch automatically
./deploy/webmail.sh reindex # build the index from existing mail
```
The index is **derived data** — every field it holds also lives in Postgres — so
it never needs backing up. If the Meilisearch volume is lost, or you switch
backends, or search ever looks stale, just rebuild it:
```bash
./deploy/webmail.sh reindex # upsert everything (safe to re-run)
./deploy/webmail.sh reindex --purge # drop stale documents first
```
---
## 8. DSM firewall breaks container-to-container traffic
**Symptom:** everything reports healthy, but `migrate` fails with an asyncpg
`TimeoutError`, or services intermittently cannot reach `postgres:5432`. Testing
by hand is maddening — the same command succeeds, then fails, then succeeds:
```
postgres:5432 - no response
postgres:5432 - accepting connections
postgres:5432 - no response
```
**Cause:** with Synology's firewall enabled, DSM puts its own chain ahead of
Docker's in `FORWARD`:
```
-A FORWARD -j FORWARD_FIREWALL <- DSM's, first, ends in -j DROP
-A FORWARD -j DEFAULT_FORWARD <- Docker's ACCEPT rules, never reached
```
and `net.bridge.bridge-nf-call-iptables=1` (the DSM default) forces traffic
between two containers *on the same bridge* through that chain. Your database
port is not in DSM's allow list — and should not be — so new connections are
dropped. The `RELATED,ESTABLISHED` accept near the top is what makes it look
intermittent rather than broken: flows that got established keep working.
Adding an iptables rule does **not** hold. DSM reloads its firewall
periodically, which flushes and rebuilds the chains; you can watch the packet
counters reset to zero and any rule you inserted disappear with them.
**Fix — take bridge traffic out of iptables entirely:**
```bash
sysctl -w net.bridge.bridge-nf-call-iptables=0
```
Two containers on one bridge are a layer-2 conversation; that flag is what
forces it through a layer-3 firewall in the first place. With it off the packets
cross the bridge directly, where DSM's reloading cannot reach them.
Verify — expect 10 out of 10:
```bash
for i in $(seq 10); do
docker run --rm --network webmail-prod postgres:16 pg_isready -h postgres -p 5432 -t 5
done
```
**Make it permanent.** *Control Panel → Task Scheduler → Create → Triggered
Task → User-defined script*, event **Boot-up**, user **root**:
```bash
sleep 60
sysctl -w net.bridge.bridge-nf-call-iptables=0
```
Re-check it after a DSM upgrade with `sysctl net.bridge.bridge-nf-call-iptables`.
### Is that safe?
Yes, for this deployment, and the scope is narrower than it first sounds.
The flag only affects packets **bridged within a single network** — that is,
containers on the same Docker network talking to each other. That they can do so
is Docker's normal behaviour on any ordinary Linux host; DSM's default is the
unusual one.
What does **not** change:
- **Isolation between different Docker networks.** That traffic is *routed*, not
bridged, so it still traverses `FORWARD` and Docker's
`DOCKER-ISOLATION-STAGE-1/2` rules still apply.
- **Traffic from your LAN or the internet into a published port.** Still routed,
still filtered by DSM's firewall. The one published port here is `WEB_PORT`.
- **Traffic from containers out to the network.** Still routed and filtered.
- **DSM's own protection.** The `INPUT` chain, which guards the NAS itself, is
untouched.
The one thing to be aware of: it is a host-wide setting, so it applies to every
bridge on the box. If you run virtual machines on a bridged network *and* rely
on DSM's firewall to filter traffic between VMs on that same bridge, that
filtering stops too. Filtering of VM traffic to anywhere else is unaffected.
## 9. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| `up` hangs on `postgres` | `DATA_ROOT` on NFS/SMB, or wrong permissions | `./deploy/webmail.sh doctor` |
| UI loads, no live updates | WebSocket not enabled in the NAS reverse proxy | §5.2 Custom Header → WebSocket |
| Login works, logs out on reload | HTTPS terminated upstream but `X-Forwarded-Proto` stripped | Use the NAS reverse proxy as documented; don't strip headers |
| "port is already allocated" | 8088 taken by another app | change `WEB_PORT` |
| `migrate` fails with asyncpg TimeoutError | DSM firewall dropping container traffic (§8) | `sysctl -w net.bridge.bridge-nf-call-iptables=0` |
| Mail not syncing | worker stopped, or credentials rejected | `./deploy/webmail.sh logs worker-sync` |
| Search finds nothing after a restore | index not rebuilt | `./deploy/webmail.sh reindex --purge` |
| App won't start after editing `.env` | malformed `MASTER_KEY` | `./deploy/webmail.sh doctor` |
Logs: `./deploy/webmail.sh logs [service] -f`. All logs are capped at 10 MB ×3
per container, so they can't fill the volume.
---
## 10. Command reference
Run these as root (`sudo -i` first): the env file is mode 600 and the data
directories are root-owned.
```
./deploy/webmail.sh init [flags] create data dirs + generate secrets
./deploy/webmail.sh show-key print MASTER_KEY so you can back it up
./deploy/webmail.sh doctor pre-flight checks
./deploy/webmail.sh up [--meili] start everything
./deploy/webmail.sh down stop (data untouched)
./deploy/webmail.sh status what's running, versions, disk, backups
./deploy/webmail.sh logs [svc] [-f] logs
./deploy/webmail.sh update [tag] backup -> migrate -> roll -> health-gate
./deploy/webmail.sh rollback [tag] back to the previous version
./deploy/webmail.sh backup verified dump, right now
./deploy/webmail.sh restore <dump> restore a dump (asks for confirmation)
./deploy/webmail.sh reindex [--purge] rebuild the search index
./deploy/webmail.sh psql database shell
./deploy/webmail.sh shell [svc] shell in a container
```