deploy: add Docker and Caddy production stack
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
coverage
|
||||||
|
*.sqlite*
|
||||||
|
*.db*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
4
Caddyfile
Normal file
4
Caddyfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mina.teppelinlabs.com {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy app:3000
|
||||||
|
}
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal 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
61
compose.yaml
Normal 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
2
deploy.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
APP_IMAGE=minabot:RELEASE
|
||||||
|
CADDY_IMAGE=caddy@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d
|
||||||
109
docs/DEPLOYMENT-PLAN.md
Normal file
109
docs/DEPLOYMENT-PLAN.md
Normal 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)
|
||||||
@@ -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
88
docs/PRODUCTION.md
Normal 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.
|
||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user