355 lines
14 KiB
Markdown
355 lines
14 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 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
|
|||
|
|
|
|||
|
|
```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
|
|||
|
|
```
|