From 97e4dc72d5debfe75a099ecaaec2a9498a1a8195 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 19 Jul 2026 22:30:37 -0400 Subject: [PATCH] Backup: chown /app/backups in image + report uid/writability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real backup run failed: pg_dump could not write to /app/backups — 'Permission denied'. Cause: the container runs as the non-root 'vyndr' user (Dockerfile USER vyndr) and the Coolify-mounted volume is root-owned, so the mount is present but unwritable. - Dockerfile now creates AND chowns /app/backups to vyndr alongside the existing /app/data + /app/.pm2 line. Docker seeds ownership into a NAMED volume on first creation, so this fixes it for a fresh volume; a host bind-mount still needs a host-side chown, which is why the next change exists. - GET /api/internal/backup/verify now reports process uid/gid, backup_dir_writable and the access errno, so the exact chown target is observable instead of guessed. A mounted-but-unwritable volume reads as 'configured' everywhere else — this makes it loud. Suite 278/3310 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA --- Dockerfile | 4 ++-- src/routes/internal.js | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index a69beac..d7c225d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,8 +60,8 @@ COPY content ./content # Persistent volume for JSONL training data (resolutions survive # redeploys via the Coolify mount). PM2_HOME lives outside it so # supervisor state is local to the container. -RUN mkdir -p /app/data/training /app/.pm2 \ - && chown -R vyndr:vyndr /app/data /app/.pm2 \ +RUN mkdir -p /app/data/training /app/.pm2 /app/backups \ + && chown -R vyndr:vyndr /app/data /app/.pm2 /app/backups \ && chmod +x /app/scripts/docker-entrypoint.sh USER vyndr diff --git a/src/routes/internal.js b/src/routes/internal.js index b95f2d0..8c13501 100644 --- a/src/routes/internal.js +++ b/src/routes/internal.js @@ -281,15 +281,33 @@ router.get('/backup/verify', async (req, res) => { const { latestDump, countRowsInDump } = require('../backupScheduler'); try { const dir = process.env.BACKUP_DIR || '/var/backups/vyndr'; + // Report identity + writability: a mounted-but-unwritable volume is the + // exact failure we hit (Coolify mounts root-owned; the container runs as + // the non-root `vyndr` user), and the fix needs the real uid/gid. + const fs = require('fs'); + let writable = false; + let dirErr = null; + try { + fs.accessSync(dir, fs.constants.W_OK); + writable = true; + } catch (e) { dirErr = e.code || e.message; } + const identity = { + uid: typeof process.getuid === 'function' ? process.getuid() : null, + gid: typeof process.getgid === 'function' ? process.getgid() : null, + backup_dir_writable: writable, + backup_dir_error: dirErr, + }; + const dump = latestDump(dir); if (!dump) { - return res.json({ ok: false, backup_dir: dir, error: 'no dump found in BACKUP_DIR' }); + return res.json({ ok: false, backup_dir: dir, ...identity, error: 'no dump found in BACKUP_DIR' }); } const table = String(req.query.table || 'ledger_entries'); const counted = await countRowsInDump(dump.path, table); return res.json({ ok: counted.ok, backup_dir: dir, + ...identity, dump: dump.file, dump_bytes: dump.size, table,