Stack-level backup: Nextcloud WebDAV + Docker volume backup via SSH

- backup container: builds from Dockerfile (adds openssh-client, curl, jq)
  fetches Nextcloud creds from Settings API at runtime, pg_dumps all DBs
  and tars Docker volumes on each app LXC via SSH, uploads to Nextcloud
  WebDAV, writes runs.ndjson log, heartbeats Uptime Kuma
- updater: gains docker-cli, Docker socket mount, /backup/runs,
  /backup/status, /backup/trigger endpoints; reads runs.ndjson for status
- kuma_data mounted read-only into backup container for local backup
- .env.example updated with SETTINGS_URL, SETTINGS_SECRET, PG_SUPERPASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-14 11:03:59 +00:00
parent cc5fc99242
commit f46fe07ca8
6 changed files with 258 additions and 39 deletions

View file

@ -2,6 +2,7 @@ import Fastify from 'fastify'
import crypto from 'crypto'
import { exec, spawn } from 'child_process'
import { promisify } from 'util'
import { readFileSync } from 'fs'
const execAsync = promisify(exec)
@ -37,6 +38,8 @@ const FORGEJO_ORG = process.env.FORGEJO_ORG || 'hotel-manage-stack'
const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || ''
const AUTH_URL = process.env.AUTH_URL || 'http://10.10.10.101:3001'
const CENTRAL_AUTH_SECRET = process.env.CENTRAL_AUTH_SECRET || ''
const BACKUP_CONTAINER = process.env.BACKUP_CONTAINER || 'management-backup-1'
const RUNS_FILE = '/backups/runs.ndjson'
// Platform services: fixed IPs, never in the user-facing apps table.
// 'management' excluded from deploy — can't redeploy the container managing deploys.
@ -297,6 +300,49 @@ app.post('/exec', async (request, reply) => {
return result
})
// ── Backup routes ─────────────────────────────────────────────────────────────
function readBackupRuns() {
try {
const content = readFileSync(RUNS_FILE, 'utf8').trim()
if (!content) return []
return content.split('\n')
.filter(Boolean)
.map(line => { try { return JSON.parse(line) } catch { return null } })
.filter(Boolean)
.reverse()
} catch {
return []
}
}
app.get('/backup/runs', async () => {
return readBackupRuns().slice(0, 30)
})
app.get('/backup/status', async () => {
const runs = readBackupRuns()
if (!runs.length) return { status: 'never', last_run: null }
const last = runs[0]
return { status: last.status, last_run: last.finished, id: last.id }
})
let backupRunning = false
app.post('/backup/trigger', async (req, reply) => {
if (backupRunning) return reply.status(409).send({ error: 'Backup already running' })
backupRunning = true
reply.status(202).send({ ok: true, message: 'Backup triggered' })
spawnAsync('docker', ['exec', BACKUP_CONTAINER, 'sh', '/etc/periodic/daily/backup'], {
timeout: 600_000,
})
.then(() => { app.log.info('Manual backup completed') })
.catch(e => { app.log.error(`Manual backup failed: ${e.message}`) })
.finally(() => { backupRunning = false })
})
app.post('/webhook', { config: { rawBody: true } }, async (request, reply) => {
const sig = request.headers['x-hub-signature-256']
|| request.headers['x-forgejo-signature']