Files
webmail-client/docs/nas-install.md
T
lukasandClaude Opus 5 5553b5febe Deploy: production Docker stack for a single host (Synology/QNAP NAS)
Everything needed to run the app on an Intel NAS that is also doing other
jobs: one published port, no host networking, memory/CPU caps and log
rotation on every container, and all state in bind mounts under DATA_ROOT
so mail survives image updates, project deletion in the NAS UI, and full
reinstalls — and so Hyper Backup/HBS3 can actually see it.

- docker/Dockerfile.backend: multi-stage, non-root (uid 10001), tini for
  the <10s graceful lease release. Installs the four plugins as entry-point
  distributions. One image, several roles via docker/entrypoint.sh
  (api | worker-sync | worker-jobs | migrate | reindex | plugins).
- docker/Dockerfile.frontend + nginx.conf: SPA + /api reverse proxy with
  the WebSocket upgrade for /api/ws, and X-Forwarded-Proto preserved so
  the API still emits __Host-/Secure cookies when TLS is terminated by
  the NAS reverse proxy (identity/cookies.py keys off request scheme).
- docker/docker-compose.prod.yml: project name webmail-prod, NOT webmail —
  docker-compose.yml (dev services) owns that name, and two compose files
  sharing one project name fight over each other's containers.
  Meilisearch is an opt-in profile; SEARCH_BACKEND=pg is the default so a
  small NAS pays nothing for search.
- migrate runs as a one-shot (core Alembic head + every plugin branch)
  that everything else waits on, so a bad migration can never leave new
  code running against an old schema.
- docker/backup.sh: nightly pg_dump verified with `pg_restore --list`
  before it counts as a backup, with retention and a baseline on first
  start.
- deploy/webmail.sh: init/doctor/up/down/status/logs/update/rollback/
  backup/restore/reindex/psql/shell. `update` is backup -> images ->
  migrate -> roll workers, api, web -> health-gate, with automatic
  rollback to the previous version if the gate fails. `doctor` refuses to
  be quiet when DATA_ROOT is on NFS/SMB (silent Postgres corruption).
- deploy/build-push.sh: build images off-NAS (the frontend build wants
  ~2GB RAM) and push to Gitea, or --save a tar to carry over.
- scripts/reindex_search.py: rebuild the search index from Postgres alone.
  Every SearchDoc field is already stored, so the index is derived data
  that never needs backing up — this closes the only real objection to
  running Meilisearch on a box whose volumes may be wiped.
- pyproject: package-data for accounts/presets.yaml and the shared plugin
  Alembic env. Without it a non-editable install (i.e. any image) silently
  ships a package that fails at runtime.
- GET /api/version, used by the deploy health gate.

Verified end to end on a real stack: fresh install, all services healthy,
core + 4 plugin migration branches applied, login/SPA/plugin bundles
through nginx, WS upgrade returning 101, __Host- cookies under a
simulated TLS proxy, backup -> restore round-trip preserving user and
plugin schema, failed-update auto-rollback (both a bad migration and a
boot-crashing API), and Meilisearch profile serving a live query.
ruff + mypy --strict clean, 363/363 backend tests pass.

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

14 KiB
Raw Blame History

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:

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

./deploy/webmail.sh init

This creates docker/.env.prod with a freshly generated MASTER_KEY and database password. Then open it and set:

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. docker/.env.prod is chmod 600 and git-ignored; treat it as a backup artefact in its own right.

2.4 Start

./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 Synology Container Manager (UI instead of SSH)

If you prefer the GUI: Container Manager → Project → Create, path /volume1/docker/webmail/src/docker, and select docker-compose.prod.yml. Container Manager reads a file named exactly .env from that folder, so run cp docker/.env.prod docker/.env first (and re-copy it whenever you change settings). The SSH route is still the better one — webmail.sh gives you the backup, update, health-gate and rollback logic that the UI has no equivalent for.

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 docker/.env.prod + 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 docker/.env.prod somewhere that is not the NAS.

Restore drill — do this once, before you need it

./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 docker/.env.prod — 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

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:

./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:

# 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 docker/.env.prod 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 (docker/.env.prod)
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:

# in docker/.env.prod:  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:

./deploy/webmail.sh reindex             # upsert everything (safe to re-run)
./deploy/webmail.sh reindex --purge     # drop stale documents first

8. 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
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.prod 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.


9. Command reference

./deploy/webmail.sh init             create data dirs + generate secrets
./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