Bot + web Dockerfiles tried to addgroup -g 1000 app on top of
node:22-alpine, which already ships a `node` group at gid 1000.
Build aborted at runtime stage 5/5 with:
addgroup: gid '1000' in use
Drop the addgroup/adduser pair on both images and just chown +
USER node onto the existing node user. Same hardening posture
(non-root, no shell login on the runtime image), one less moving
part. The compose dev overlay's `user: ${HOST_UID:-1000}:${HOST_GID:-1000}`
matches uid 1000 either way.
Plus:
- New docker-compose.portainer.yml: pulls cm-whatsapp-{bot,web}
from gitea.04080616.xyz/yiekheng instead of building from
source. Named volumes for sessions / media so the operator
doesn't need shell access to manage state. Healthchecks on
both services so Portainer's UI surfaces unhealthy containers.
- New docs/deploy-portainer.md walking through registry auth,
stack creation, env vars, migrations, first sign-in, future
redeploys, rollbacks.
- README links the Portainer guide alongside the dev path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6.8 KiB
Deploying via Portainer
End-to-end deploy steps for a fresh Portainer-managed host. Targets
the standard cm-whatsapp-bot pair of images published by
scripts/publish.sh.
0. Prerequisites
- Portainer 2.x running on the target host (CE or EE both fine).
- A Postgres reachable from that host (the
wabotdatabase with the pgcrypto / pg_trgm extensions enabled — run migrations from any machine that can reach the DB before the stack is brought up). - A pull credential for
gitea.04080616.xyz— a Gitea personal access token with theread:packagesscope. Generate one in Gitea → User Settings → Applications. - A reverse proxy / Cloudflare Tunnel pointing at
http://<portainer-host>:<WEB_PORT>if the deploy needs to be reachable on the public domain (e.g.wabot.04080616.xyz).
1. Add the registry to Portainer
Portainer → Registries → + Add registry → Custom registry.
| Field | Value |
|---|---|
| Name | gitea.04080616.xyz |
| Registry URL | gitea.04080616.xyz |
| Authentication | enabled |
| Username | your Gitea username |
| Password | the read:packages PAT |
Save. The registry must show as connected before continuing — if the
test pull fails, the stack will hang on pull later.
2. Push the images (on your dev machine)
# Login once (sudo path matches scripts/dev.sh by default)
sudo docker login gitea.04080616.xyz
# Push :latest. Tag explicitly with DOCKER_IMAGE_TAG=v1.x.y if you
# want pinned-tag deploys (recommended for prod — never deploy
# `latest` if you can avoid it; tag versions per release).
NO_SUDO=1 ./scripts/publish.sh latest
publish.sh builds + pushes both images:
gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:<tag>gitea.04080616.xyz/yiekheng/cm-whatsapp-web:<tag>
3. Create the Portainer stack
Portainer → Stacks → + Add stack.
Name: cm-whatsapp-bot
Build method: "Web editor" or "Repository". Either is fine —
"Repository" pointing at this repo's master and the file
docker-compose.portainer.yml is the cleanest path because future
deploys are just "Pull and redeploy" inside Portainer.
Web editor path: copy the contents of
docker-compose.portainer.yml
into the editor verbatim.
Repository path:
| Field | Value |
|---|---|
| Repository URL | http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v1.git |
| Reference | refs/heads/master |
| Compose path | docker-compose.portainer.yml |
| Authentication | enabled (same Gitea PAT as step 1) |
| Auto-update | optional — enabled lets Portainer redeploy on every push |
4. Set environment variables
In the same stack form, scroll to Environment variables and add:
| Key | Value |
|---|---|
DATABASE_URL |
postgres://wabot:PASS@192.168.0.210:5432/wabot |
AUTH_SECRET |
output of scripts/gen_auth_secret.sh |
WEB_PORT |
host port (e.g. 9000) |
DOCKER_IMAGE_TAG |
latest (or a pinned v1.x.y) |
OPERATOR_TOKEN_VERSION |
1 (bump only when you want to invalidate every existing session) |
BOT_LOG_LEVEL |
info |
Optional tuning (defaults are fine for most installs):
| Key | Default | When to bump |
|---|---|---|
BOT_FIRE_CONCURRENCY |
8 |
More accounts firing in parallel |
BOT_GROUP_CONCURRENCY |
3 |
More groups per fire — but careful with WhatsApp rate caps |
BOT_MAX_SEND_PER_MINUTE |
40 |
Aged accounts can push toward 60 |
5. Run database migrations
The stack does NOT auto-migrate on boot. Apply migrations from any machine that can reach the same Postgres:
DATABASE_URL='postgres://...' \
./scripts/db.sh migrate
If the journal is non-monotonic, the migrate runner refuses with a
clear error and prints which _journal.json entry to bump (the
guard added in commit 47d7c53 + the CI test in
apps/web/src/test/drizzle-journal-monotonic.test.ts).
Then seed the bootstrap operator + set its password:
DATABASE_URL='postgres://...' SEED_OPERATOR_USERNAME=admin \
./scripts/db.sh seed
DATABASE_URL='postgres://...' \
./scripts/set-password.sh admin # reads the password from stdin
6. Deploy the stack
In Portainer → click Deploy the stack. Watch the container list in Containers:
cmbot-botshould show running, healthy within ~20 s.cmbot-webshould show running, healthy within ~30 s (Next.js cold boot is the bottleneck).
If a container shows unhealthy, check Logs:
| Symptom | Likely cause |
|---|---|
column "email" does not exist |
Migrations weren't applied. Run step 5. |
Server is not configured for sign-in |
AUTH_SECRET blank or missing. Set it in stack env. |
pg-boss: queue policy ...standard |
Harmless first-boot log; the bot force-flips it. |
Stream Errored (restart required) (Baileys) |
Upstream noise; ignore unless pairing fails. |
7. First sign-in
Visit https://<your-domain>/login, sign in as admin with the
password set in step 5, and walk the
docs/runbook.md smoke checklist before declaring
the deploy good.
8. Future redeploys
Two paths depending on how you set up step 3:
Web editor flow:
- Run
scripts/publish.sh <tag>on your dev machine. - In Portainer → Stack → "Update the stack" → "Re-pull image and redeploy".
Repository flow:
- Run
scripts/publish.sh <tag>. - Commit any compose / env changes to master.
- Portainer → Stack → "Pull and redeploy". (If auto-update is on, skip this — Portainer redeploys on every push.)
Always pin a tag (v1.4.2) instead of latest for production —
makes rollback a one-field stack edit instead of a republish.
Rolling back
In Portainer → Stack → set DOCKER_IMAGE_TAG=v1.4.1 (or whatever
the previous good tag was) → Re-pull and redeploy. The cmbot-* data
volumes (sessions, media) are preserved across image swaps, so a
rollback doesn't lose pairings or uploaded media.
If the schema also rolled back, run the corresponding down SQL by
hand — drizzle's migrator only goes forward, by design.