Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ tmpfs:
- /run # nginx (PID file)
```

Applied to: cloudflared, webmail, n8n runner.
Applied to: cloudflared, mailflow frontend + backend, n8n runner.

`calcom` is **not** read-only — its entrypoint installs NPM packages and writes a build cache on startup, which breaks under `read_only: true`. Keep `cap_drop: [ALL]` + `no-new-privileges:true`, omit `read_only`/`tmpfs`.

Expand Down
22 changes: 22 additions & 0 deletions web1/ansible/migrations/20261004_0004_remove_webmail_stack.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
# Migration: 20261004_0004_remove_webmail_stack
# Description: Remove the retired tmail-web "webmail" stack (replaced by mailflow)
# Problem: dropping a stack from docker_stacks and containers/ leaves its
# (stopped) container and /opt/containers/webmail behind on the host
# Solution: compose down the old project, then delete its directory

- name: Check for webmail compose project
ansible.builtin.stat:
path: /opt/containers/webmail/docker-compose.yml
register: webmail_compose

- name: Remove webmail containers
community.docker.docker_compose_v2:
project_src: /opt/containers/webmail
state: absent
when: webmail_compose.stat.exists

- name: Remove webmail project directory
ansible.builtin.file:
path: /opt/containers/webmail
state: absent
6 changes: 3 additions & 3 deletions web1/ansible/roles/system/containers/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
docker_stacks:
webmail:
env_file: false
state: stopped
mailflow:
env_file: true
state: present
roundcube:
env_file: true
state: present
Expand Down
7 changes: 0 additions & 7 deletions web1/ansible/roles/system/containers/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,6 @@
scope: "global"
state: present

- name: Ensure webmail env.file exists
ansible.builtin.copy:
content: ""
dest: /opt/containers/webmail/env.file
force: false
mode: "0644"

- name: Ensure roundcube .env exists
ansible.builtin.copy:
content: ""
Expand Down
47 changes: 47 additions & 0 deletions web1/containers/mailflow/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ── MailFlow backend ───────────────────────────────────────
# Postgres credentials live in .env.postgres (postgres container).
NODE_ENV=production
PORT=3000

# Public URL users see in the browser (invite emails, CORS).
# FRONTEND_URL must be the same value.
APP_URL=https://mail.example.com
FRONTEND_URL=https://mail.example.com

# Bundled services — DB_NAME/DB_USER/DB_PASSWORD must match .env.postgres
DB_HOST=postgres
DB_PORT=5432
DB_NAME=mailflow
DB_USER=mailflow
REDIS_URL=redis://redis:6379

# Host reverse proxy + MailFlow's own nginx. Safe because the frontend is only
# published on 127.0.0.1. The proxy must send X-Forwarded-Proto: https.
TRUST_PROXY_HOPS=2

# Secrets
# openssl rand -hex 32
SESSION_SECRET=
# openssl rand -hex 16 — same value as POSTGRES_PASSWORD in .env.postgres
DB_PASSWORD=
# openssl rand -hex 32 — encrypts stored IMAP/SMTP credentials at rest.
# Losing or changing it means every account must be re-authenticated.
ENCRYPTION_KEY=

# Image versions are pinned in docker-compose.yml and bumped by Renovate
UPDATE_CHECK_DISABLED=true

# ── Web push (optional) ────────────────────────────────────
# npx web-push generate-vapid-keys
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:admin@example.com

# ── Google OAuth for Gmail / Workspace (optional) ──────────
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GOOGLE_REDIRECT_URI=https://mail.example.com/oauth/google/callback

# ── IMAP tuning (optional) ─────────────────────────────────
# Max persistent IDLE connections per mail server; empty = unlimited
# IMAP_MAX_PERSISTENT_PER_HOST=
7 changes: 7 additions & 0 deletions web1/containers/mailflow/.env.postgres.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# PostgreSQL — consumed only by the postgres container.
# Keep these in sync with DB_NAME / DB_USER / DB_PASSWORD in .env.
POSTGRES_DB=mailflow
POSTGRES_USER=mailflow
POSTGRES_PASSWORD=
# PG18: version-specific PGDATA; the volume mounts at /var/lib/postgresql
PGDATA=/var/lib/postgresql/18/docker
113 changes: 113 additions & 0 deletions web1/containers/mailflow/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
# MailFlow — unified IMAP/SMTP webmail (https://github.com/maathimself/mailflow)
# Adapted from upstream docker-compose.ghcr.yml: HTTP only on localhost (TLS is
# terminated by the host reverse proxy), bundled Caddy profile dropped.
services:
frontend:
image: ghcr.io/maathimself/mailflow-frontend:3.8.1@sha256:8f4b3e6cc7ccf045669edd4f2887bcd6de970377b6c95d48f7b1e8430563be96 # yamllint disable-line rule:line-length
container_name: mailflow-frontend
restart: unless-stopped
cap_drop:
- ALL
cap_add:
# nginx master binds :80/:443, creates its temp dirs and drops workers
# to the nginx user
- NET_BIND_SERVICE
- CHOWN
- SETUID
- SETGID
security_opt:
- no-new-privileges:true
# Upstream supports read-only roots: the entrypoint's config rewrites are
# best-effort and the baked-in resolver (127.0.0.11) is Docker's.
read_only: true
tmpfs:
- /tmp
- /run
- /var/cache/nginx
# Self-signed cert for the unused internal :443 listener, regenerated
# on each start
- /etc/nginx/ssl
ports:
- "127.0.0.1:3008:80"
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO/dev/null http://127.0.0.1:80/ || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
networks:
- mailflow
- app-infra

backend:
image: ghcr.io/maathimself/mailflow-backend:3.8.1@sha256:c98193a87a232661895cf5300e4f886733d34410b68c408a347c6ebadc838e98 # yamllint disable-line rule:line-length
container_name: mailflow-backend
restart: unless-stopped
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
networks:
- mailflow

postgres:
image: postgres:18.6-alpine3.23@sha256:885cf05d376c7cf27afef02073e6bdac3841252537f16e244fd1c1e6a7c99fb1
container_name: mailflow-postgres
restart: unless-stopped
env_file:
- .env.postgres
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
networks:
- mailflow

redis:
image: valkey/valkey:9.1-alpine3.23@sha256:c9b77919daeba2c02ad954d0c844cc4e7142069d177b89c5fd771f405daf9e02 # yamllint disable-line rule:line-length
container_name: mailflow-redis
restart: unless-stopped
command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
networks:
- mailflow

volumes:
postgres_data:
redis_data:

networks:
mailflow:
app-infra:
external: true
24 changes: 0 additions & 24 deletions web1/containers/webmail/docker-compose.yml

This file was deleted.