The install guide was SSH-first and left the GUI as a footnote, but the two things it could not do were both fixable rather than inherent. - `init` takes --data-root / --domain / --tz / --port / --bind, so configuring is one command instead of hand-editing a dotfile that File Station hides by default. Re-running with flags updates an existing .env.prod. - `show-key` prints MASTER_KEY, so it can be backed up without opening that hidden file — intended to be redirected to a file, copied into a password manager, and deleted. - usage() printed a hardcoded line range that spilled into the code once the header grew; it now prints the leading comment block however long it gets. - docs §2.5 is a complete GUI walkthrough: Gitea "Download ZIP" + File Station (with the chmod a zip download needs), two one-off Task Scheduler jobs for config and key backup, then the Container Manager project on the repo root named webmail-prod. Plus a table for running update/rollback/backup/restore /doctor/reindex as Task Scheduler jobs, and why the UI's own Build action is not a substitute for `update` (no pre-backup, no health gate, no rollback). Verified by running the documented sequence verbatim: flag-driven init, show-key, then `docker compose up -d --wait` from the project folder as the GUI does — all services healthy, account created, backup and doctor tasks working against the result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
433 lines
17 KiB
Markdown
433 lines
17 KiB
Markdown
# 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 `docker/.env.prod` 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.
|
||
> `docker/.env.prod` 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**. 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`** | `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
|
||
|
||
```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 `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
|
||
|
||
```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 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:
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```bash
|
||
./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
|
||
```
|