name: usesend networks: nginx-bridge: external: true services: redis: image: redis:7 container_name: usesend-redis hostname: usesend-redis networks: - nginx-bridge env_file: .env labels: com.centurylinklabs.watchtower.enable: 'true' volumes: - ./volumes/redis:/data:Z command: - redis-server - --maxmemory-policy - noeviction tty: true stdin_open: true restart: unless-stopped # S3-compatible storage for images uploaded in the email editor, following # upstream's docker/prod/compose.yml (RustFS replaced MinIO, whose images are # no longer published). The browser uploads straight to S3_COMPATIBLE_API_URL # and sent emails link to S3_COMPATIBLE_PUBLIC_URL, so NPM proxies # storage.usesend.gbrown.org -> http://usesend-storage:9000. No host ports: # the console (9001) is only reachable from inside nginx-bridge. # Pinned like upstream. Bump the tag deliberately when upstream does. rustfs: image: docker.io/rustfs/rustfs:1.0.4 container_name: usesend-storage hostname: usesend-storage networks: - nginx-bridge # RustFS runs as uid 10001; keep-id keeps ./volumes/rustfs gib-owned (AGENTS.md §3.2) userns_mode: keep-id:uid=10001,gid=10001 security_opt: - no-new-privileges:true environment: RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?err} RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:?err} RUSTFS_ADDRESS: 0.0.0.0:9000 RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001 RUSTFS_CONSOLE_ENABLE: 'true' RUSTFS_OBS_LOGGER_LEVEL: error TZ: America/New_York volumes: - ./volumes/rustfs:/data:Z command: ["/data"] healthcheck: test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"] interval: 20s timeout: 10s retries: 5 start_period: 40s restart: unless-stopped # One-shot: creates the bucket, allows browser uploads from the app (CORS) and # makes objects publicly readable so images load in sent emails. Reruns on # every unit start, which is harmless. rustfs-init: image: docker.io/amazon/aws-cli:2.34.30 container_name: usesend-storage-init networks: - nginx-bridge entrypoint: ["/bin/sh", "-ec"] environment: AWS_ACCESS_KEY_ID: ${RUSTFS_ACCESS_KEY:?err} AWS_SECRET_ACCESS_KEY: ${RUSTFS_SECRET_KEY:?err} AWS_DEFAULT_REGION: us-east-1 AWS_EC2_METADATA_DISABLED: 'true' BUCKET: ${S3_COMPATIBLE_BUCKET:-unsend} APP_ORIGIN: ${USESEND_URL:?err} depends_on: rustfs: condition: service_healthy command: - | s3="aws --endpoint-url http://usesend-storage:9000 s3api" $$s3 head-bucket --bucket "$$BUCKET" >/dev/null 2>&1 || $$s3 create-bucket --bucket "$$BUCKET" $$s3 put-bucket-cors --bucket "$$BUCKET" --cors-configuration \ '{"CORSRules":[{"AllowedOrigins":["'"$$APP_ORIGIN"'"],"AllowedMethods":["GET","HEAD","PUT"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"],"MaxAgeSeconds":3600}]}' $$s3 put-bucket-policy --bucket "$$BUCKET" --policy \ '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::'"$$BUCKET"'/*"]}]}' restart: 'no' smtp-server: image: usesend/smtp-proxy:latest container_name: usesend-smtp-server hostname: usesend-smtp networks: nginx-bridge: # The proxy REQUIRES STARTTLS before it will accept AUTH, so clients must # reach it by a name the certificate actually covers: smtp.usesend.gbrown.org, # validated by the *.usesend.gbrown.org SAN on npm-43 (see certs/README.md). # # Resolution: podman's aardvark-dns answers this name authoritatively for # containers ON nginx-bridge, handing back the container IP. NPM is not # involved at all -- it only reverse-proxies HTTP(S). Cloudflare is not # involved either: there is deliberately no public DNS record for this name, # so if the container is stopped, lookups fail fast with NXDOMAIN. aliases: - smtp.usesend.gbrown.org env_file: .env environment: - SMTP_AUTH_USERNAME=${SMTP_USER:?err} - USESEND_BASE_URL=${USESEND_URL:?err} # The image's built-in default cert cannot complete a TLS 1.3 handshake at all # ("no suitable signature algorithm"), which silently made STARTTLS -- and so # ALL inbound SMTP -- impossible. These point it at the real wildcard cert. - USESEND_API_CERT_PATH=/certs/fullchain.pem - USESEND_API_KEY_PATH=/certs/privkey.pem ports: # Bound to the WireGuard address ONLY, never 0.0.0.0 (AGENTS.md §13.2). This is an # authenticated relay; putting it on the public interface would invite the internet to # brute-force it, and contradicts the Cloudflare-only posture in §7. # The `Home` wg interface sits in firewalld's `trusted` zone, so no rule is needed. # Rootless can bind these because net.ipv4.ip_unprivileged_port_start = 80. # Consumers on the home server reach it as smtp.usesend.gbrown.org (a UniFi DNS record # maps that name to 192.168.2.2) so the cert still validates by hostname. - "192.168.2.2:587:587" - "192.168.2.2:465:465" volumes: # Copied from NPM's letsencrypt store, NOT bind-mounted from it: NPM mounts that # tree with :Z (private SELinux category), so a second consumer would fight it. # TRADE-OFF: copies go stale. Refresh them when NPM renews (see certs/README). - ./certs:/certs:ro,Z labels: com.centurylinklabs.watchtower.enable: 'true' tty: true stdin_open: true restart: unless-stopped usesend: image: usesend/usesend:latest container_name: usesend hostname: usesend domainname: ${USESEND_URL:?err} networks: - nginx-bridge env_file: .env environment: - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 - PORT=${USESEND_PORT:-3000} - DATABASE_URL=${DATABASE_URL:?err} - NEXTAUTH_URL=${USESEND_URL:?err} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err} - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:?err} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:?err} - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err} - GITHUB_ID=${GITHUB_ID:?err} - GITHUB_SECRET=${GITHUB_SECRET:?err} - REDIS_URL=${REDIS_URL:?err} - NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false} - API_RATE_LIMIT=${API_RATE_LIMIT:-1} - SMTP_HOST=${SMTP_HOST:-smtp.usesend.gbrown.org} - SMTP_USER=${SMTP_USER:-usesend} # Editor image uploads (the rustfs service above). Uploads are disabled if any is unset. - S3_COMPATIBLE_ACCESS_KEY=${RUSTFS_ACCESS_KEY:?err} - S3_COMPATIBLE_SECRET_KEY=${RUSTFS_SECRET_KEY:?err} - S3_COMPATIBLE_API_URL=${S3_COMPATIBLE_API_URL:?err} - S3_COMPATIBLE_PUBLIC_URL=${S3_COMPATIBLE_PUBLIC_URL:?err} - S3_COMPATIBLE_BUCKET=${S3_COMPATIBLE_BUCKET:-unsend} labels: com.centurylinklabs.watchtower.enable: 'true' depends_on: redis: condition: service_started tty: false stdin_open: false restart: unless-stopped