From 6d1d12953cf81c80fa372793163b9f96dc597dc6 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 7 Sep 2026 11:50:22 +0200 Subject: [PATCH] deploy: add Docker and Caddy production stack --- .dockerignore | 11 ++++ Caddyfile | 4 ++ Dockerfile | 26 ++++++++++ compose.yaml | 61 ++++++++++++++++++++++ deploy.env.example | 2 + docs/DEPLOYMENT-PLAN.md | 109 ++++++++++++++++++++++++++++++++++++++++ docs/OPERATIONS.md | 3 ++ docs/PRODUCTION.md | 88 ++++++++++++++++++++++++++++++++ src/index.ts | 2 +- 9 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 deploy.env.example create mode 100644 docs/DEPLOYMENT-PLAN.md create mode 100644 docs/PRODUCTION.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..07066b7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.env +.env.* +node_modules +dist +data +coverage +*.sqlite* +*.db* +*.log +.DS_Store diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..fa3ad35 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,4 @@ +mina.teppelinlabs.com { + encode zstd gzip + reverse_proxy app:3000 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a779819 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +ARG BUN_IMAGE=oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 +FROM ${BUN_IMAGE} AS build +WORKDIR /app +COPY package.json bun.lock bunfig.toml ./ +RUN bun install --frozen-lockfile +COPY . . +RUN bun run build + +FROM ${BUN_IMAGE} AS dependencies +WORKDIR /app +COPY package.json bun.lock bunfig.toml ./ +RUN bun install --frozen-lockfile --production + +FROM ${BUN_IMAGE} AS runtime +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=dependencies /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/src ./src +COPY --from=build /app/scripts ./scripts +COPY --from=build /app/drizzle ./drizzle +COPY package.json bunfig.toml ./ +ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 DATABASE_PATH=/data/minabot.sqlite +USER bun +EXPOSE 3000 +CMD ["sh", "-c", "bun scripts/migrate.ts && exec bun scripts/start.ts"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a8663c6 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,61 @@ +name: minabot +services: + app: + image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image} + env_file: + - path: .env.production + format: raw + volumes: + - /srv/minabot/data:/data + restart: unless-stopped + init: true + security_opt: + - no-new-privileges:true + cap_drop: [ALL] + healthcheck: + test: [CMD, bun, -e, "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + logging: + driver: local + options: + max-size: 10m + max-file: "3" + caddy: + image: ${CADDY_IMAGE:?Set CADDY_IMAGE to a pinned image digest} + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + restart: unless-stopped + depends_on: + app: + condition: service_healthy + logging: + driver: local + options: + max-size: 10m + max-file: "3" + monitor: + image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image} + command: [bun, scripts/monitor.ts] + environment: + MONITOR_URL: https://mina.teppelinlabs.com/api/health + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: [ALL] + read_only: true + logging: + driver: local + options: + max-size: 10m + max-file: "3" +volumes: + caddy_data: + caddy_config: diff --git a/deploy.env.example b/deploy.env.example new file mode 100644 index 0000000..a93c0fc --- /dev/null +++ b/deploy.env.example @@ -0,0 +1,2 @@ +APP_IMAGE=minabot:RELEASE +CADDY_IMAGE=caddy@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d diff --git a/docs/DEPLOYMENT-PLAN.md b/docs/DEPLOYMENT-PLAN.md new file mode 100644 index 0000000..bed4e2b --- /dev/null +++ b/docs/DEPLOYMENT-PLAN.md @@ -0,0 +1,109 @@ +# Minabot deployment plan + +Target: `https://mina.teppelinlabs.com` on `syntaxbullet@46.62.156.93`. +Prepared September 7, 2026. Executed the same day; see +[production operations](PRODUCTION.md) for the deployed configuration and results. + +## Verified baseline + +- Ubuntu 26.04.1, x86_64, 3.7 GiB RAM, approximately 34 GiB free disk. +- DNS A resolves to this server; no AAAA answer was returned. +- Docker and Caddy are not installed. Only SSH is publicly listening. +- SSH keys work, password authentication is disabled, UFW and Fail2ban are active. +- Passwordless sudo is installed in `/etc/sudoers.d/90-syntaxbullet` and verified + with `sudo -k` followed by `sudo -n id` in a fresh SSH connection. +- The checkout has uncommitted live Discord image changes and migrations 0009/0010. + Include and validate the intended complete release before tagging it. + +## Architecture + +Internet → Caddy container on TCP 80/443 → app container on port 3000 → SQLite. + +Use Docker Compose with one app process and one Caddy container on a shared bridge +network. The app needs outbound access to Discord. Publish only Caddy's ports; +do not publish app port 3000 or mount the Docker socket into either container. +Keep reminders, backups, and live Discord updates in the existing app process. +Multiple app replicas are unsuitable for the current live-image worker. + +Use `/opt/minabot` for deployment configuration and tagged releases, +`/srv/minabot/data` mounted as `/data` for SQLite and snapshots, and persistent +Docker volumes for Caddy `/data` and `/config`. Match data ownership to the app's +non-root container UID. Use `restart: unless-stopped`, bounded container logs, +a health check, and a sufficient startup grace period for migrations/backups. +An unhealthy health check alone does not restart a container; monitor it separately. + +## Implementation sequence + +1. Add `HOST` configuration to `src/index.ts`, retaining `127.0.0.1` locally and + using `HOST=0.0.0.0` inside Docker. The current hard-coded loopback binding + prevents Caddy from reaching the app across containers. +2. Add a multi-stage Dockerfile, `.dockerignore`, Compose file, Caddyfile, and + deployment environment example. Pin tested Bun/Caddy versions and image digests. + Build Linux amd64 dependencies in the image using the frozen lockfile; never + copy macOS `node_modules`. Exclude real dotenv files, databases, backups, and Git. +3. Preserve the existing startup contract: migrations must succeed before + `scripts/start.ts` loads `dist/index.js`. Include migrations and required script + imports, production dependencies, built assets, and the bundled Instrument Serif + font. Use a glibc-based Bun runtime with a sans-serif font installed, and verify + the native `@napi-rs/canvas` renderer inside that exact image. +4. Install Docker Engine and Compose from Docker's official Ubuntu repository. + Ubuntu 26.04 is supported. Keep Docker's firewall management enabled; published + ports can bypass UFW. Publish only 80/443 and verify exposure externally. Align + the existing Fail2ban nftables action with the chosen Docker firewall backend + (prefer an iptables-compatible SSH action for the standard Docker backend), + then recheck SSH protection. Open TCP 80/443 in UFW and any Hetzner firewall. +5. Put runtime secrets in `/opt/minabot/.env.production` with mode 0600 and inject + through Compose `env_file`. Set `NODE_ENV=production`, `HOST=0.0.0.0`, `PORT=3000`, + `APP_ORIGIN=https://mina.teppelinlabs.com`, and `DATABASE_PATH=/data/minabot.sqlite`. + Supply `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, a stable random + `AUTH_COOKIE_SECRET`, and the bot/channel settings. Do not bake secrets into + images or build arguments. Register this Discord OAuth redirect exactly: + `https://mina.teppelinlabs.com/api/auth/discord/callback`. +6. Default to a fresh production database. If existing local data should be moved, + use a verified SQLite snapshot, not a live database-file copy. Avoid running + two workers against copied live Discord message state. Resolve the data choice + before first production startup. +7. Build and tag the release, start it on the private Docker network, verify its + health, then start Caddy. Caddy obtains and renews TLS certificates and redirects + HTTP to HTTPS. Persist its certificate storage across container replacement. + +Proposed Caddyfile: + +```caddyfile +mina.teppelinlabs.com { + encode zstd gzip + reverse_proxy app:3000 +} +``` + +## Verification and operations + +- Before release: typecheck, tests, build, and disposable production API smoke test. + Validate Compose and Caddy configuration; run migrations, health, static assets, + native PNG rendering, and a restart/persistence test inside the Linux image. +- After launch: verify public HTTPS, HTTP redirect, `/api/health`, Discord sign-in, + secure cookies, habit persistence, and port 3000 being unreachable externally. + Exercise bot workers with stubbed transports; a live Discord post or DM needs + an explicitly selected test destination and authorization to send it. +- Keep daily verified snapshots and seven retained copies using the existing app + backup system. Configure an off-server destination separately; none is provisioned + yet. Test restore to a new database with the matching image before relying on it. +- Configure an independent HTTPS monitor and alert destination. Existing monitor + scripts provide checks but do not provision an external monitoring service. +- Updates: build/test the next immutable image, snapshot the database, stop the + old app, and start the new release. Accept brief downtime; do not overlap workers. + Keep the previous image. If schema compatibility prevents image-only rollback, + stop the app and restore its matching pre-migration snapshot to a new file. + The restore tool revokes sessions and disables reminders; audit restored live + Discord state before starting workers. Never use `docker compose down -v`. + +Remaining launch inputs: fresh database versus migration, Discord redirect setup +and production credentials, off-server backup destination, and monitoring alerts. + +## References + +- [Docker Ubuntu installation](https://docs.docker.com/engine/install/ubuntu/) +- [Docker firewall behavior](https://docs.docker.com/engine/network/packet-filtering-firewalls/) +- [Bun Docker guide](https://bun.sh/guides/ecosystem/docker) +- [Caddy automatic HTTPS](https://caddyserver.com/docs/automatic-https) +- [Existing backup and recovery workflow](OPERATIONS.md) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 827fb39..b0c695f 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -1,5 +1,8 @@ # Deployment operations +See [production deployment](PRODUCTION.md) for the Docker/Caddy server, release, +data paths, verification results, and current backup/monitoring limitations. + ## Backups and recovery Production creates a verified SQLite snapshot before migrations and automatically diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md new file mode 100644 index 0000000..a8355c9 --- /dev/null +++ b/docs/PRODUCTION.md @@ -0,0 +1,88 @@ +# Minabot production + +Live: https://mina.teppelinlabs.com + +SSH: `ssh syntaxbullet@46.62.156.93` (key authentication; passwordless sudo). +Deployment root: `/opt/minabot`. Compose manages `app`, `caddy`, and `monitor`. +Docker starts at boot; containers use `restart: unless-stopped`. + +## Release and configuration + +Current image: `minabot:20260907-c7aa5a1-2`, built on the Linux amd64 server from +the local checkout based on `c7aa5a1` plus the Docker and trusted-proxy changes. +Its immutable image ID is recorded by `docker image inspect`; source is retained +under `/opt/minabot/releases/20260907-c7aa5a1-2`, with `source-v2.tar.gz` and build +logs in `/opt/minabot`. Bun 1.3.14 and Caddy images are pinned by digest. + +`/opt/minabot/.env` selects release images. `/opt/minabot/.env.production` contains +runtime secrets (0600). Discord client and bot credentials were copied from the +effective development environment; production has a separate generated cookie +signing secret. Runtime uses `APP_ORIGIN=https://mina.teppelinlabs.com`, +`TRUST_PROXY=true`, `HOST=0.0.0.0`, and `DATABASE_PATH=/data/minabot.sqlite`. + +`TRUST_PROXY` only accepts HTTPS from `X-Forwarded-Proto`, retains the actual +request Host, and must only be enabled behind the private Caddy proxy. Port 3000 +is not published. Caddy exposes TCP 80/443 and persists certificate state in +`minabot_caddy_data` and `minabot_caddy_config`. SSH remains on 22. Fail2ban uses +the iptables-compatible SSH action alongside Docker/UFW. + +The registered Discord callback is +`https://mina.teppelinlabs.com/api/auth/discord/callback`; localhost remains registered. + +## Data and backups + +`/srv/minabot/data` is bind-mounted at `/data`, owned by UID 1000. A verified +`VACUUM INTO` snapshot copied the development database, including its one account, +four habits, history, reminders, and live Discord state. The transfer hash and +counts are recorded in `/opt/minabot/migration-manifest.json`. Local development +was stopped before the snapshot so only production manages the existing live cards. +Do not restart development with the same bot/message state; use stubbed previews +or an isolated database and bot configuration. + +Daily verified snapshots and pre-migration snapshots are stored under +`/srv/minabot/data/backups`, retaining seven snapshots. The restore rehearsal +passed using a separate file; production data was not replaced by the rehearsal. +Restore revokes sessions and disables reminder opt-ins; see [OPERATIONS.md](OPERATIONS.md). + +Off-server backup replication and delivered uptime alerts are **not configured**: +the operator has no destinations set up yet. The monitor checks public HTTPS every +minute in a separate container on this server and writes failure/recovery events +to its logs. It cannot report a complete server outage to an external destination. + +## Common commands + +Run after SSH login: + +```sh +cd /opt/minabot +sudo docker compose ps +sudo docker compose logs --tail=100 app caddy monitor +sudo docker compose exec -T app bun scripts/backup.ts +sudo docker compose exec -T monitor bun scripts/monitor.ts --once +``` + +When sending several shell commands over SSH stdin, redirect noninteractive +`docker compose exec` calls from `/dev/null` so they do not consume later commands. + +For updates, build a new release tag, run the container smoke test, take a backup, +update `APP_IMAGE` in `.env`, stop the old app, then run +`sudo docker compose up -d --wait --wait-timeout 120`. Never run overlapping app +workers or `docker compose down -v`. Keep the last known-good image and a matching +pre-migration snapshot. The initial `-1` image predates the proxy fix and is not +a suitable rollback target for working production sign-in. + +## Verified September 7, 2026 + +- Typecheck, build, 195 tests, and production HTTP smoke tests passed. +- The Linux runtime passed migrations, native 1920×1080 PNG rendering, and + persistence across a process restart using a disposable database. +- Public HTTPS health returns `{"status":"ok"}`; HTTP redirects to HTTPS. +- Browser Discord sign-in completed and displayed the migrated account and habits. +- Read-only authenticated production API/PNG checks passed; unauthenticated access + and cross-origin writes were rejected. Temporary verification sessions were removed. +- Snapshot integrity, restore rehearsal, and transferred database hash passed. +- Application port 3000 is unpublished and an external HTTP probe timed out. +- Docker and Fail2ban are active; runtime secrets and the SQLite file are mode 0600. + +Live Discord test posts/DMs were not sent. Existing configured workers continue +their normal production behavior. diff --git a/src/index.ts b/src/index.ts index 15a7ac9..f3684a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,7 @@ const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, rea if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing()); const server = Bun.serve({ - hostname: "127.0.0.1", + hostname: process.env.HOST ?? "127.0.0.1", port: Number(process.env.PORT ?? 3000), routes: { "/api": req => app.fetch(req),