# Docker Cloud 24/7 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Run Maquina Comercial Pessoal 24/7 on Oracle Cloud Always Free using Docker Compose, persistent data, HTTPS, backups, and Telegram alerts when WhatsApp or campaign safety requires operator action. **Architecture:** Add Docker-ready network binding and health endpoints to the app, package it in a Node 24 container, and orchestrate it with Caddy via Docker Compose. Persist all runtime state under `/app/data`, keep restart-safe campaign behavior, and add a focused Telegram alert module with deduplication. **Tech Stack:** Node.js 24 ESM, SQLite, Baileys, Docker, Docker Compose, Caddy, Telegram Bot API over HTTPS, Node test runner. ## Global Constraints - Do not remove the current safety behavior that pauses campaigns after server restart. - Do not send real WhatsApp messages during tests. - Do not commit `.env.production`, Telegram tokens, database files, WhatsApp auth files, or backups. - Local default host remains `127.0.0.1`; Docker sets `APP_HOST=0.0.0.0`. - Persist SQLite, WhatsApp auth, exports, and backups under the mounted `data` directory. - Telegram alerts must be deduplicated with a cooldown per alert key. - Telegram token must never be printed to logs. - Docker healthcheck must use an endpoint that does not require login and does not expose secrets. - Caddy handles public HTTP/HTTPS; the app container must not publish its internal port directly to the public internet. - Oracle Cloud target is Always Free Ubuntu VM with Docker Compose. --- ## File Structure - Modify `src/env.mjs`: add `APP_HOST`, Docker-safe runtime path envs, and Telegram env helpers. - Modify `scripts/servidor.mjs`: bind to `APP_HOST`, initialize alerting, and notify restart/WhatsApp conditions. - Modify `src/server/index.mjs`: expose a no-auth health endpoint. - Create `src/alerts/telegram.mjs`: Telegram client, alert dedupe, and safe notification helpers. - Create `tests/alerts.test.mjs`: unit tests for Telegram alert behavior and dedupe. - Modify existing server/env tests: cover `APP_HOST` and health route. - Create `Dockerfile`: production image using Node 24. - Create `.dockerignore`: exclude data, auth, backups, test artifacts, and secrets. - Create `docker-compose.yml`: app + Caddy with persistent volumes and healthcheck. - Create `Caddyfile.example`: reverse proxy template for a real domain. - Create `.env.production.example`: production environment template without secrets. - Create `scripts/backup-data.mjs`: zip SQLite + WhatsApp auth backups with retention. - Create `docs/deployment/oracle-free-docker.md`: operator deployment runbook. --- ### Task 1: Docker-Ready Environment and Health Endpoint **Files:** - Modify: `src/env.mjs` - Modify: `scripts/servidor.mjs` - Modify: `src/server/index.mjs` - Modify/Test: `tests/server.test.mjs` - Modify/Test: `tests/personal-branding.test.mjs` **Interfaces:** - Produces: `APP_HOST: string` exported from `src/env.mjs` - Produces: unauthenticated `GET /api/health` - Consumes: existing `APP_PORT`, `paths`, `loadDotEnv`, `createAppServer` - [ ] **Step 1: Write failing env test for host binding** Add a test that imports `src/env.mjs` with `process.env.APP_HOST='0.0.0.0'` and asserts the exported value is `0.0.0.0`. Clear the env afterward. ```js test('env exposes APP_HOST with local-safe fallback', async () => { const previous = process.env.APP_HOST; process.env.APP_HOST = '0.0.0.0'; const env = await import(`../src/env.mjs?host-test=${Date.now()}`); assert.equal(env.APP_HOST, '0.0.0.0'); if (previous === undefined) delete process.env.APP_HOST; else process.env.APP_HOST = previous; }); ``` - [ ] **Step 2: Write failing health route test** In `tests/server.test.mjs`, create an app server with fake dependencies and assert `GET /api/health` returns 200 without authentication. ```js test('health endpoint is public and does not expose secrets', async () => { const { server, baseUrl, close } = await startTestServer(); try { const res = await fetch(`${baseUrl}/api/health`); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.ok, true); assert.ok(body.app); assert.ok(body.database); assert.ok(body.whatsapp); assert.doesNotMatch(JSON.stringify(body), /token|password|secret|cookie/i); } finally { await close(server); } }); ``` - [ ] **Step 3: Run RED tests** Run: `node --test tests/server.test.mjs tests/personal-branding.test.mjs` Expected: FAIL because `APP_HOST` and `/api/health` do not exist yet. - [ ] **Step 4: Implement `APP_HOST`** In `src/env.mjs`, add: ```js export let APP_HOST = envString('APP_HOST', '127.0.0.1'); ``` Update `refreshEnvDerivedValues()`: ```js APP_HOST = envString('APP_HOST', '127.0.0.1'); ``` - [ ] **Step 5: Bind server to `APP_HOST`** In `scripts/servidor.mjs`, import `APP_HOST` and replace: ```js server.listen(port, '127.0.0.1', () => { ``` with: ```js server.listen(port, APP_HOST, () => { ``` Update logs so local and Docker hosts are explicit: ```js log(`Maquina Comercial Pessoal rodando em http://${APP_HOST}:${port}`); if (APP_HOST === '127.0.0.1') log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.'); else log('modo container/rede: proteja o acesso com proxy HTTPS e senha forte.'); ``` - [ ] **Step 6: Implement public health endpoint** In `src/server/index.mjs`, add a route before auth-protected application routes: ```js router.add('GET', '/api/health', async (req, res, { json }) => { json(200, { ok: true, app: 'maquina-comercial-pessoal', database: 'open', whatsapp: whatsapp.getStatus?.().state || 'UNKNOWN' }); }); ``` If the router context does not expose `whatsapp` there, wire it from the existing `createAppServer({ db, jobs, whatsapp, log })` closure. - [ ] **Step 7: Run GREEN tests** Run: `node --test tests/server.test.mjs tests/personal-branding.test.mjs` Expected: PASS. - [ ] **Step 8: Run focused syntax check** Run: `node --check src/env.mjs` and `node --check scripts/servidor.mjs` Expected: PASS. --- ### Task 2: Telegram Alert Module **Files:** - Create: `src/alerts/telegram.mjs` - Create/Test: `tests/alerts.test.mjs` - Modify: `src/env.mjs` - Modify: `.env.example` **Interfaces:** - Produces: `createTelegramAlerter({ token, chatId, enabled, fetchImpl, now, cooldownMs, log })` - Produces: `alerter.alert(key, text): Promise<{ sent: boolean, reason?: string }>` - Consumes: Telegram Bot API `sendMessage` - [ ] **Step 1: Write failing tests for disabled, missing config, send success, and dedupe** Create `tests/alerts.test.mjs`: ```js import test from 'node:test'; import assert from 'node:assert/strict'; import { createTelegramAlerter } from '../src/alerts/telegram.mjs'; test('telegram alert skips when disabled or missing config', async () => { const calls = []; const alerter = createTelegramAlerter({ enabled: false, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) }); assert.deepEqual(await alerter.alert('qr', 'QR necessario'), { sent: false, reason: 'disabled' }); assert.equal(calls.length, 0); }); test('telegram alert sends message without leaking token to text', async () => { const calls = []; const fetchImpl = async (url, options) => { calls.push({ url, options }); return { ok: true, json: async () => ({ ok: true }) }; }; const alerter = createTelegramAlerter({ enabled: true, token: '123:SECRET', chatId: '456', fetchImpl }); assert.deepEqual(await alerter.alert('qr', 'WhatsApp precisa de QR'), { sent: true }); assert.equal(calls.length, 1); assert.match(calls[0].url, /sendMessage$/); const payload = JSON.parse(calls[0].options.body); assert.equal(payload.chat_id, '456'); assert.equal(payload.text, 'WhatsApp precisa de QR'); assert.doesNotMatch(payload.text, /SECRET/); }); test('telegram alert deduplicates per key within cooldown', async () => { let current = 1000; const calls = []; const fetchImpl = async (...args) => { calls.push(args); return { ok: true, json: async () => ({ ok: true }) }; }; const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => current, cooldownMs: 1000 }); assert.equal((await alerter.alert('same', 'A')).sent, true); assert.deepEqual(await alerter.alert('same', 'A again'), { sent: false, reason: 'cooldown' }); current = 2501; assert.equal((await alerter.alert('same', 'A later')).sent, true); assert.equal(calls.length, 2); }); ``` - [ ] **Step 2: Run RED test** Run: `node --test tests/alerts.test.mjs` Expected: FAIL because module does not exist. - [ ] **Step 3: Implement Telegram alerter** Create `src/alerts/telegram.mjs`: ```js export function createTelegramAlerter({ token, chatId, enabled = false, fetchImpl = fetch, now = () => Date.now(), cooldownMs = 15 * 60 * 1000, log = () => {} } = {}) { const sentAt = new Map(); const configured = Boolean(token && chatId); async function alert(key, text) { if (!enabled) return { sent: false, reason: 'disabled' }; if (!configured) return { sent: false, reason: 'not_configured' }; const last = sentAt.get(key) || 0; if (now() - last < cooldownMs) return { sent: false, reason: 'cooldown' }; const response = await fetchImpl(`https://api.telegram.org/bot${token}/sendMessage`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text }) }); if (!response.ok) { log(`telegram: falha ao enviar alerta (${response.status || 'sem status'})`); return { sent: false, reason: 'request_failed' }; } sentAt.set(key, now()); return { sent: true }; } return { alert }; } ``` - [ ] **Step 4: Add env values** In `src/env.mjs`, export: ```js export const telegram = { enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true', botToken: process.env.TELEGRAM_BOT_TOKEN || '', chatId: process.env.TELEGRAM_CHAT_ID || '' }; ``` After `loadDotEnv()` refresh, this object must reflect `.env`; if constants are initialized too early, use a function instead: ```js export function telegramConfig() { return { enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true', botToken: process.env.TELEGRAM_BOT_TOKEN || '', chatId: process.env.TELEGRAM_CHAT_ID || '' }; } ``` Prefer the function if tests expose stale values. - [ ] **Step 5: Document env placeholders** Append to `.env.example`: ```dotenv TELEGRAM_ALERTS_ENABLED=false TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= ``` - [ ] **Step 6: Run GREEN tests** Run: `node --test tests/alerts.test.mjs` Expected: PASS. --- ### Task 3: Wire Operational Alerts Into Server Lifecycle **Files:** - Modify: `scripts/servidor.mjs` - Modify/Test: `tests/server.test.mjs` or create `tests/server-alerts.test.mjs` **Interfaces:** - Consumes: `createTelegramAlerter(...)` - Consumes: `telegramConfig()` from `src/env.mjs` - Produces: startup alerts for restart pause and WhatsApp reconnect failures - [ ] **Step 1: Write failing server alert tests with fake alerter** Create a small exported helper if needed: ```js export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) { if (campaignsPausedByRestart) await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Revise antes de reiniciar.'); if (whatsappStatus === 'QR_REQUIRED') await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.'); } ``` Test: ```js test('startup alerts notify restart pause and QR requirement once', async () => { const alerts = []; const alerter = { alert: async (key, text) => alerts.push({ key, text }) }; await notifyStartupState({ alerter, whatsappStatus: 'QR_REQUIRED', campaignsPausedByRestart: true }); assert.deepEqual(alerts.map((a) => a.key), ['campaigns-paused-restart', 'whatsapp-qr']); }); ``` - [ ] **Step 2: Run RED test** Run: `node --test tests/server-alerts.test.mjs` Expected: FAIL because helper/wiring does not exist. - [ ] **Step 3: Implement alert helper** Create `src/alerts/runtime-alerts.mjs` if keeping `scripts/servidor.mjs` slim: ```js export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) { if (campaignsPausedByRestart) { await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Acesse o painel, revise a fila e reinicie manualmente.'); } if (whatsappStatus === 'QR_REQUIRED') { await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel da Maquina Comercial Pessoal.'); } } export async function notifyWhatsAppTransition({ alerter, previousState, nextState }) { if (previousState === nextState) return; if (previousState === 'READY' && nextState !== 'READY') { await alerter.alert('whatsapp-left-ready', `WhatsApp saiu de READY e agora esta ${nextState}.`); } if (nextState === 'QR_REQUIRED') { await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.'); } } ``` - [ ] **Step 4: Wire startup alert in `scripts/servidor.mjs`** After `pauseCampaignsAfterRestart(db)`, determine whether any campaign has `pause_reason='server_restart'`: ```js const campaignsPausedByRestart = Boolean(db.prepare( "SELECT 1 FROM outreach_campaigns WHERE status = 'paused' AND pause_reason = 'server_restart' LIMIT 1" ).get()); ``` Create alerter: ```js const tg = telegramConfig(); const alerter = createTelegramAlerter({ enabled: tg.enabled, token: tg.botToken, chatId: tg.chatId, log }); ``` After WhatsApp manager exists, call: ```js await notifyStartupState({ alerter, whatsappStatus: whatsapp.getStatus().state, campaignsPausedByRestart }); ``` - [ ] **Step 5: Alert on initial reconnect failure** Change the existing reconnect catch: ```js whatsapp.connect().catch(async (error) => { log(`whatsapp: reconexao inicial falhou: ${error.message}`); await alerter.alert('whatsapp-initial-reconnect-failed', 'WhatsApp nao reconectou no startup. Acesse o painel e verifique o QR.'); }); ``` - [ ] **Step 6: Run focused tests** Run: `node --test tests/alerts.test.mjs tests/server-alerts.test.mjs` Expected: PASS. --- ### Task 4: Docker and Compose Packaging **Files:** - Create: `Dockerfile` - Create: `.dockerignore` - Create: `docker-compose.yml` - Create: `Caddyfile.example` - Create: `.env.production.example` - Modify/Test: `tests/personal-branding.test.mjs` **Interfaces:** - Consumes: `APP_HOST`, `APP_PORT`, `APP_DB_PATH`, `APP_WHATSAPP_AUTH_DIR` - Produces: Docker image runnable by `docker compose up -d --build` - [ ] **Step 1: Write failing file existence/config test** Add to `tests/personal-branding.test.mjs`: ```js test('docker deployment files preserve persistent data and do not include secrets', async () => { const dockerfile = await read('Dockerfile'); const compose = await read('docker-compose.yml'); const envExample = await read('.env.production.example'); const caddy = await read('Caddyfile.example'); assert.match(dockerfile, /FROM node:24/); assert.match(compose, /restart: unless-stopped/); assert.match(compose, /APP_HOST=0\.0\.0\.0/); assert.match(compose, /APP_DB_PATH=\/app\/data\/maquina-pessoal\.db/); assert.match(compose, /APP_WHATSAPP_AUTH_DIR=\/app\/data\/runtime\/whatsapp-auth/); assert.match(caddy, /reverse_proxy app:3910/); assert.doesNotMatch(envExample, /[0-9]{5,}:[A-Za-z0-9_-]{20,}/); }); ``` - [ ] **Step 2: Run RED test** Run: `node --test tests/personal-branding.test.mjs` Expected: FAIL because files do not exist. - [ ] **Step 3: Create Dockerfile** ```dockerfile FROM node:24-bookworm-slim WORKDIR /app ENV NODE_ENV=production COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY src ./src COPY scripts ./scripts COPY public ./public COPY prospeccao/config.json ./prospeccao/config.json RUN mkdir -p /app/data/runtime /app/data/exports /app/data/backups EXPOSE 3910 CMD ["npm", "start"] ``` - [ ] **Step 4: Create `.dockerignore`** ```gitignore data node_modules test-results playwright-report .env .env.production *.log *.sqlite *.db .git ``` - [ ] **Step 5: Create `docker-compose.yml`** ```yaml services: app: build: . restart: unless-stopped env_file: - .env.production environment: APP_HOST: 0.0.0.0 APP_PORT: 3910 APP_DB_PATH: /app/data/maquina-pessoal.db APP_WHATSAPP_AUTH_DIR: /app/data/runtime/whatsapp-auth CNPJ_BASE_DIR: /app/prospeccao/cnpj volumes: - ./data:/app/data expose: - "3910" healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3910/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 5s retries: 5 start_period: 30s caddy: image: caddy:2-alpine restart: unless-stopped depends_on: app: condition: service_healthy ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config volumes: caddy_data: caddy_config: ``` - [ ] **Step 6: Create `Caddyfile.example`** ```caddyfile campanhas.seudominio.com { encode gzip reverse_proxy app:3910 } ``` - [ ] **Step 7: Create `.env.production.example`** ```dotenv APP_RESET_PASSWORD=troque-esta-senha GOOGLE_PLACES_API_KEY= PERSONAL_COMMERCIAL_WHATSAPP= TELEGRAM_ALERTS_ENABLED=true TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= ``` - [ ] **Step 8: Run GREEN tests** Run: `node --test tests/personal-branding.test.mjs` Expected: PASS. - [ ] **Step 9: Build Docker image** Run: `docker compose build app` Expected: image builds successfully. --- ### Task 5: Backup Script With Retention **Files:** - Create: `scripts/backup-data.mjs` - Create/Test: `tests/backup-data.test.mjs` - Modify: `package.json` **Interfaces:** - Produces: CLI `node scripts/backup-data.mjs` - Produces: npm script `backup:data` - Consumes: `paths.data`, `paths.db`, `paths.whatsappAuth` - [ ] **Step 1: Write failing backup tests** Create test that uses a temp directory, sample DB file, sample auth file, and asserts a backup file is created and old backups are removed. ```js test('backup creates archive marker and keeps configured retention', async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-')); const dataDir = path.join(tmp, 'data'); await fs.mkdir(path.join(dataDir, 'runtime', 'whatsapp-auth'), { recursive: true }); await fs.writeFile(path.join(dataDir, 'maquina-pessoal.db'), 'db'); await fs.writeFile(path.join(dataDir, 'runtime', 'whatsapp-auth', 'session.json'), 'auth'); const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 }); assert.match(result.file, /maquina-pessoal-2026-08-03/); await fs.access(result.file); }); ``` - [ ] **Step 2: Run RED test** Run: `node --test tests/backup-data.test.mjs` Expected: FAIL because backup module does not exist. - [ ] **Step 3: Implement backup helper** Because the project has no zip dependency, implement a safe tar/gzip through Node `child_process` only on Linux containers, or write a plain `.backup` directory copy. Prefer no new production dependency. Export: ```js export async function createBackup({ dataDir = paths.data, now = () => new Date(), retention = 14 } = {}) { const stamp = now().toISOString().slice(0, 10); const backupsDir = path.join(dataDir, 'backups'); const target = path.join(backupsDir, `maquina-pessoal-${stamp}`); await fs.mkdir(target, { recursive: true }); await fs.copyFile(path.join(dataDir, 'maquina-pessoal.db'), path.join(target, 'maquina-pessoal.db')); await cp(path.join(dataDir, 'runtime', 'whatsapp-auth'), path.join(target, 'whatsapp-auth'), { recursive: true }); await pruneBackups(backupsDir, retention); return { file: target }; } ``` Use directory backups rather than zip if avoiding dependencies. Document this in the runbook. - [ ] **Step 4: Add CLI guard** At bottom of `scripts/backup-data.mjs`: ```js if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) { createBackup().then((result) => console.log(`backup criado: ${result.file}`)).catch((error) => { console.error(`backup falhou: ${error.message}`); process.exit(1); }); } ``` - [ ] **Step 5: Add npm script** In `package.json` scripts: ```json "backup:data": "node scripts/backup-data.mjs" ``` - [ ] **Step 6: Run GREEN tests** Run: `node --test tests/backup-data.test.mjs` Expected: PASS. --- ### Task 6: Deployment Runbook and Final Verification **Files:** - Create: `docs/deployment/oracle-free-docker.md` - Modify: `README.md` **Interfaces:** - Consumes: Docker/Compose files and Telegram envs from earlier tasks - Produces: operator runbook for Oracle Always Free deployment - [ ] **Step 1: Write runbook** Create `docs/deployment/oracle-free-docker.md` with exact steps: ```markdown # Oracle Always Free Docker Deployment ## 1. Create VM - Use Ubuntu Always Free. - Prefer Ampere A1 if available. - Keep boot/block volume within Always Free limits. ## 2. Open ports - OCI ingress: 80/tcp and 443/tcp from internet. - Restrict 22/tcp to your IP. ## 3. Install Docker ```bash sudo apt-get update sudo apt-get install -y ca-certificates curl gnupg curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER ``` ## 4. Configure app ```bash cp .env.production.example .env.production nano .env.production cp Caddyfile.example Caddyfile nano Caddyfile ``` ## 5. Start ```bash docker compose up -d --build docker compose ps docker compose logs -f app ``` ## 6. Backup ```bash docker compose exec app npm run backup:data ``` ## 7. QR recovery When Telegram says QR is required, open the panel and reconnect WhatsApp. ``` ``` - [ ] **Step 2: Link runbook from README** Add a short section to `README.md`: ```markdown ## Deploy 24/7 sem custo Veja `docs/deployment/oracle-free-docker.md` para rodar no Oracle Cloud Always Free com Docker, Caddy e alertas Telegram. ``` - [ ] **Step 3: Run final automated checks** Run: ```powershell npm.cmd test npm.cmd run test:browser npm.cmd audit --omit=dev docker compose config docker compose build app ``` Expected: - Node tests pass. - Browser tests pass or any existing unrelated flake is documented. - Audit reports 0 production vulnerabilities. - Compose config validates. - Docker image builds. - [ ] **Step 4: Manual non-sending smoke test** Run: ```powershell docker compose up -d --build Invoke-WebRequest http://127.0.0.1/api/health -UseBasicParsing docker compose restart app docker compose logs --tail=100 app ``` Expected: - Health returns 200 through Caddy if locally mapped. - Data directory remains present after restart. - Campaigns pause after restart. - Telegram alert is sent for restart pause if enabled. - No real WhatsApp message is sent. --- ## Self-Review - Spec coverage: Oracle Free, Docker Compose, Caddy HTTPS, persistent volumes, Telegram alerts, restart safety, backups, and verification are each assigned to tasks. - Placeholder scan: no TBD/TODO placeholders remain; every task has concrete files, commands, and expected outcomes. - Interface consistency: `APP_HOST`, `/api/health`, `createTelegramAlerter`, `notifyStartupState`, and `backup:data` are defined before later tasks consume them.