deploy: add Docker and Caddy production stack

This commit is contained in:
syntaxbullet
2026-09-07 11:50:22 +02:00
parent 50d7838de3
commit 6d1d12953c
9 changed files with 305 additions and 1 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.env
.env.*
node_modules
dist
data
coverage
*.sqlite*
*.db*
*.log
.DS_Store

4
Caddyfile Normal file
View File

@@ -0,0 +1,4 @@
mina.teppelinlabs.com {
encode zstd gzip
reverse_proxy app:3000
}

26
Dockerfile Normal file
View File

@@ -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"]

61
compose.yaml Normal file
View File

@@ -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:

2
deploy.env.example Normal file
View File

@@ -0,0 +1,2 @@
APP_IMAGE=minabot:RELEASE
CADDY_IMAGE=caddy@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d

109
docs/DEPLOYMENT-PLAN.md Normal file
View File

@@ -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)

View File

@@ -1,5 +1,8 @@
# Deployment operations # 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 ## Backups and recovery
Production creates a verified SQLite snapshot before migrations and automatically Production creates a verified SQLite snapshot before migrations and automatically

88
docs/PRODUCTION.md Normal file
View File

@@ -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.

View File

@@ -25,7 +25,7 @@ const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, rea
if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing()); if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing());
const server = Bun.serve({ const server = Bun.serve({
hostname: "127.0.0.1", hostname: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT ?? 3000), port: Number(process.env.PORT ?? 3000),
routes: { routes: {
"/api": req => app.fetch(req), "/api": req => app.fetch(req),