Synology Container Manager and QNAP Container Station build a project from a folder and look for `docker-compose.yml` in it. Pointing either at `docker/` finds the DEV service stack and starts the wrong thing, so production now has a root-level entry point. - docker-compose.yml at the repo root: `include`s the prod stack (relative paths resolve against the included file's directory, so the build context and backup.sh still resolve) and pins `name: webmail-prod`. A project name typed into the GUI overrides the file's, so the docs say to type the same one. - webmail.sh init now drops a `.env` symlink to docker/.env.prod in the repo root: the GUI reads `.env` from the project folder, and a symlink keeps one file to edit instead of two copies that drift. - Required-variable guards on DATA_ROOT / MASTER_KEY / POSTGRES_PASSWORD / APP_DOMAIN. A bare `docker compose up` with no env file used to interpolate DATA_ROOT to "" and bind-mount /postgres at the root of the filesystem; it now aborts with a message naming the variable. - require_env no longer sources the env file. Sourcing executed it as shell: ENABLED_PLUGINS=calendar contacts labels filters ran `contacts` as a command and broke every subcommand, and anything else in the file that holds the master key would have been executed too. Replaced with a compose-compatible KEY=VALUE parser; the template quotes the value as well. - doctor: no longer aborts on a not-yet-created DATA_ROOT (set -e + pipefail killed it inside a `df` assignment — on exactly the pre-install run where its NFS/SMB check is the one that matters), and probes the nearest existing ancestor so that check works before the directory exists. - docs §2.5 rewritten with what the GUI can and cannot do: no compose profiles (so no Meilisearch), no health-gated update, no restore; plus the Task Scheduler route for generating MASTER_KEY without enabling SSH, and why init/migrate showing "Exited (0)" is correct. Verified by running `docker compose up -d --wait` from the repo root with no -f and no --env-file (what the GUI does): all services healthy, migrations applied, app served, and deploy/webmail.sh manages the same project. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
16 KiB
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 doctorchecks 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_KEYinto 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.prodischmod 600and 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 (GUI instead of SSH)
Yes, this runs from the GUI. Container Manager → Project → Create, point it at
the repo root (/volume1/docker/webmail/src), and name the project
webmail-prod.
Point it at the repo root, not at docker/. Both folders contain a
docker-compose.yml: the one in docker/ is the throwaway dev service stack
(Postgres/Redis/Meilisearch/GreenMail on dev ports) and starting it does not
give you a webmail server. The root one is production.
Name the project webmail-prod too — the name you type in the UI overrides the
one in the compose file, and keeping them the same means deploy/webmail.sh
and the GUI manage the same stack instead of two rival copies.
You still need a shell once, to create docker/.env.prod with a generated
MASTER_KEY — there is no way to generate a random key in the GUI. Either SSH
in and run ./deploy/webmail.sh init, or, if you would rather not enable SSH at
all, use Control Panel → Task Scheduler → Create → Scheduled Task → User-defined
script, run as root, with:
cd /volume1/docker/webmail/src && ./deploy/webmail.sh init
Run it once, then edit docker/.env.prod in DSM's Text Editor (init also puts
a .env symlink in the repo root, which is what Container Manager reads — one
file, one place to edit).
What you get in the GUI: start/stop, live logs, per-container resource use,
automatic restart after reboot, and the nightly database backups (the backup
service is an ordinary container, so it runs regardless of how you started the
stack).
What the GUI cannot do:
| Why | Instead | |
|---|---|---|
| Start Meilisearch | compose profiles aren't exposed in the UI | ./deploy/webmail.sh up — or delete the profiles: line from docker/docker-compose.prod.yml |
| Health-gated updates | it just pulls and restarts; no gate, no rollback | ./deploy/webmail.sh update |
| Backup before migrating | — | ./deploy/webmail.sh update does it automatically |
| Restore a dump | — | ./deploy/webmail.sh restore <file> |
| Pre-flight checks, reindex | — | ./deploy/webmail.sh doctor / reindex |
Two things that look wrong but aren't: init and migrate sit there as
Exited (0) — they are one-shot jobs that did their work and stopped, and
every other service waited for them. And the first start builds both images on
the NAS, which is slow and wants ~2 GB of RAM; §6.3 shows how to build them on
your PC instead.
After editing .env, containers keep their old values until they are recreated
— ./deploy/webmail.sh up does that cleanly.
The honest recommendation: install and update over SSH, and use the GUI for day-to-day watching, starting and stopping. The update path is where all the safety lives, and it is the one thing the GUI 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/webmailshared folder. Add Snapshot Replication on a Btrfs volume for fast local rollback. - QNAP: HBS 3 → the
Container/webmailfolder. 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:
- Install Docker, copy
$DATA_ROOTback from your backup. - Copy the repo and
docker/.env.prod— with the originalMASTER_KEY. ./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:
- Takes a verified database backup. No backup, no update.
- Builds (or pulls) the new images. A failure here changes nothing.
- Runs migrations as a one-shot container — before any service restarts, so a bad migration never leaves new code running against an old schema.
- 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.
- 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