diff --git a/.gitignore b/.gitignore index b131317..7d0da5c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env.dev docker/.env.prod docker/.env +/.env .env.*.local gitea-server-credentials.md diff --git a/deploy/webmail.sh b/deploy/webmail.sh index 2b227e2..fbdde56 100755 --- a/deploy/webmail.sh +++ b/deploy/webmail.sh @@ -35,10 +35,30 @@ compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" } +# Read KEY=VALUE pairs the way compose's .env parser does, WITHOUT sourcing. +# Sourcing would execute the file as shell — it would choke on an ordinary +# value containing spaces (ENABLED_PLUGINS) and would run anything else that +# found its way into the file that holds the master key. +load_env_file() { + local line key val + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ''|'#'*) continue ;; esac + case "$line" in *=*) ;; *) continue ;; esac + key="${line%%=*}" + val="${line#*=}" + # Trim whitespace around the key; strip one layer of surrounding quotes. + key="$(printf '%s' "$key" | tr -d '[:space:]')" + case "$val" in + \"*\") val="${val#\"}"; val="${val%\"}" ;; + \'*\') val="${val#\'}"; val="${val%\'}" ;; + esac + [ -n "$key" ] && export "${key}=${val}" + done < "$1" +} + require_env() { [ -f "$ENV_FILE" ] || die "$ENV_FILE not found. Run: ./deploy/webmail.sh init" - # shellcheck disable=SC1090 - set -a; . "$ENV_FILE"; set +a + load_env_file "$ENV_FILE" [ -n "${DATA_ROOT:-}" ] || die "DATA_ROOT is not set in $ENV_FILE" [ -n "${MASTER_KEY:-}" ] || die "MASTER_KEY is not set in $ENV_FILE" [ -n "${POSTGRES_PASSWORD:-}" ] || die "POSTGRES_PASSWORD is not set in $ENV_FILE" @@ -80,6 +100,9 @@ cmd_init() { sed "s|^MASTER_KEY=.*|MASTER_KEY=${master}|; s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${pgpass}|" \ "$ENV_FILE" > "${ENV_FILE}.tmp" && mv "${ENV_FILE}.tmp" "$ENV_FILE" chmod 600 "$ENV_FILE" + # The NAS GUIs read `.env` from the project folder (the repo root). + # A symlink keeps one source of truth instead of two copies to drift. + [ -e "${ROOT_DIR}/.env" ] || ln -s docker/.env.prod "${ROOT_DIR}/.env" 2>/dev/null || true grn "created $ENV_FILE with freshly generated secrets" ylw "" ylw " >> BACK UP MASTER_KEY NOW. Copy it into your password manager. <<" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6048065 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +# Production stack, at the repo root so the NAS GUIs find it. +# +# Synology Container Manager and QNAP Container Station create a project from a +# folder and look for `docker-compose.yml` in it. Pointing them at `docker/` +# would find the DEV service stack (docker/docker-compose.yml: throwaway +# Postgres/Redis/Meilisearch/GreenMail on published dev ports) and start the +# wrong thing entirely — so the production entry point lives here instead. +# +# Everything real is in docker/docker-compose.prod.yml; `include` resolves that +# file's relative paths against its own directory, so the build context and +# backup.sh still resolve correctly from here. +# +# Config comes from `.env` in this directory — `deploy/webmail.sh init` creates +# it as a symlink to docker/.env.prod so there is only ever one copy to edit. +# +# GUI: create a project on this folder, name it `webmail-prod` +# CLI: ./deploy/webmail.sh up (preferred: adds backup, health-gated +# updates, rollback and restore) + +name: webmail-prod + +include: + - docker/docker-compose.prod.yml diff --git a/docker/.env.prod.example b/docker/.env.prod.example index 954cb0f..8c6ee0d 100644 --- a/docker/.env.prod.example +++ b/docker/.env.prod.example @@ -76,7 +76,7 @@ OAUTH_MS_CLIENT_SECRET= # Plugins whose schema is migrated on every start. Remove one to stop shipping # its tables; `hello` is the SDK demo and is intentionally not listed. -ENABLED_PLUGINS=calendar contacts labels filters +ENABLED_PLUGINS="calendar contacts labels filters" # --- Backups ---------------------------------------------------------------- BACKUP_AT=03:30 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index dd56908..2c03e51 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -31,10 +31,10 @@ x-backend-image: &backend-image GIT_SHA: ${GIT_SHA:-unknown} x-backend-env: &backend-env - APP_DOMAIN: ${APP_DOMAIN} - DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-webmail}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-webmail} + APP_DOMAIN: "${APP_DOMAIN:?set APP_DOMAIN in your .env}" + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-webmail}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in your .env}@postgres:5432/${POSTGRES_DB:-webmail} REDIS_URL: redis://redis:6379/0 - MASTER_KEY: ${MASTER_KEY} + MASTER_KEY: "${MASTER_KEY:?set MASTER_KEY in your .env - generate one with openssl rand -base64 32}" BLOB_ROOT: /data/blobs SEARCH_BACKEND: ${SEARCH_BACKEND:-pg} MEILI_URL: ${MEILI_URL:-http://meilisearch:7700} @@ -110,7 +110,7 @@ services: - effective_cache_size=${PG_EFFECTIVE_CACHE_SIZE:-768MB} # Full page writes stay ON: NAS filesystems (btrfs/ext4) are not 8k-atomic. volumes: - - ${DATA_ROOT}/postgres:/var/lib/postgresql/data + - ${DATA_ROOT:?set DATA_ROOT in your .env (see docker/.env.prod.example)}/postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-webmail} -d ${POSTGRES_DB:-webmail}"] interval: 10s diff --git a/docs/nas-install.md b/docs/nas-install.md index f09cbf1..e97536e 100644 --- a/docs/nas-install.md +++ b/docs/nas-install.md @@ -106,14 +106,62 @@ 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) +### 2.5 Synology Container Manager (GUI 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. +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: + +```bash +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 ` | +| 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