From 7272c2dae39edb4092ad8a11d3b9ea9dc3f9435e Mon Sep 17 00:00:00 2001 From: X4Applegate Date: Thu, 17 Sep 2026 16:48:11 -0700 Subject: [PATCH 1/3] feat: scheduled off-host database backups (SQLite) Closes #104. A background loop writes a consistent VACUUM INTO snapshot to an admin-configured directory on an interval, keeping the newest N; point it at a mounted volume/share/object-store gateway to get copies off the host. A 'Back up now' button runs one on demand. MariaDB is skipped (platform-managed backups). - Settings -> Backup: enable, directory, interval hours, retention count - StartBackupScheduler wakes every 10m and backs up when the newest backup is older than the interval (survives restarts, honours config changes live) - prune keeps the newest N; only caddyui-backup-*.db files are touched - tests: config load, prune-keeps-newest, and a real VACUUM round-trip --- CHANGELOG.md | 6 + README.md | 3 +- cmd/caddyui/main.go | 2 + internal/server/backup_schedule.go | 211 ++++++++++++++++++++++++ internal/server/backup_schedule_test.go | 108 ++++++++++++ internal/server/server.go | 35 +++- internal/server/settings_sections.go | 5 + web/templates/settings.html | 30 ++++ 8 files changed, 390 insertions(+), 10 deletions(-) create mode 100644 internal/server/backup_schedule.go create mode 100644 internal/server/backup_schedule_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1975d84..a1caf50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versi --- +## [2.52.0] - 2026-09-17 - Scheduled backups & SSO login + +### Added + +- **Scheduled off-host database backups** ([issue #104](https://github.com/X4Applegate/caddyui/issues/104)): under **Settings → Backup**, enable automatic SQLite snapshots (`VACUUM INTO`) written to a directory on an interval, keeping the newest N. Point the directory at a mounted volume, network share, or object-store gateway to keep copies off the host. A **Back up now** button runs one on demand. (MariaDB installs continue to use their platform's own backup tooling.) + ## [2.51.0] - 2026-09-17 - Per-host rate limiting ### Added diff --git a/README.md b/README.md index 35950cd..e31f82c 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,9 @@ run CaddyUI directly in an LXC, VM, or bare-metal host. ## Features -### Analytics, certificates and local services in v2.39 – v2.51 +### Analytics, certificates and local services in v2.39 – v2.52 +- **Scheduled off-host backups** *(v2.52.0)* — automatic SQLite snapshots to a directory on an interval (keep the newest N), plus a **Back up now** button, under Settings → Backup. Point the directory at a mounted volume or share to keep copies off the host. - **Per-host rate limiting** *(v2.51.0)* — cap a host at *N requests per M seconds, per client IP* (429 on excess), via the `caddy-ratelimit` module now built into `Dockerfile.caddy`. Rebuild your custom Caddy image to use it. - **Block an IP from analytics** *(v2.50.0)* — a visitor's drill-down page gains one-click **Block on this host** and **Block everywhere (fleet-wide)** actions; the global blocklist is managed under Settings → Security. Turns "who's probing me?" into a one-click 403. - **Configurable verification resolver** *(v2.49.0)* — point the deploy/readiness DNS checks at trusted, internal, or split-horizon resolvers (plain DNS servers or a DoH URL) under **Settings → DNS**, instead of the default Cloudflare DNS-over-HTTPS — for networks where outbound DNS to Cloudflare is filtered. diff --git a/cmd/caddyui/main.go b/cmd/caddyui/main.go index 9f320fc..c62d208 100644 --- a/cmd/caddyui/main.go +++ b/cmd/caddyui/main.go @@ -139,6 +139,8 @@ Fix by: srv.StartCustomCertificateProbes(pollerCtx) // v2.42.0: safety-net pass for certificate exports (event-driven otherwise). srv.StartCertificateExports(pollerCtx) + // v2.52.0 (issue #104): scheduled off-host database backups (SQLite). + srv.StartBackupScheduler(pollerCtx) // Opt-in startup sync. Default: no initial sync — pushing an empty config // would wipe Caddy's existing routes. Set CADDYUI_SYNC_ON_START=1 once all diff --git a/internal/server/backup_schedule.go b/internal/server/backup_schedule.go new file mode 100644 index 0000000..fc36b15 --- /dev/null +++ b/internal/server/backup_schedule.go @@ -0,0 +1,211 @@ +package server + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + appdb "github.com/X4Applegate/caddyui/internal/db" + "github.com/X4Applegate/caddyui/internal/models" +) + +// Scheduled off-host database backups (issue #104). A background loop writes a +// consistent SQLite snapshot (VACUUM INTO) to an admin-configured directory on +// an interval, keeping the newest N. Point the directory at a mounted volume / +// network share / object-store gateway to get the copy off the host. MariaDB is +// skipped — those installs use their platform's own backup tooling. +const ( + settingBackupScheduleEnabled = "backup_schedule_enabled" + settingBackupScheduleDir = "backup_schedule_dir" + settingBackupScheduleInterval = "backup_schedule_interval_hours" + settingBackupScheduleKeep = "backup_schedule_keep" + + backupFilePrefix = "caddyui-backup-" + backupFileSuffix = ".db" +) + +type backupScheduleConfig struct { + Enabled bool + Dir string + IntervalHours int + Keep int // 0 = keep all +} + +func loadBackupScheduleConfig(getSetting func(string) string) backupScheduleConfig { + interval, _ := strconv.Atoi(strings.TrimSpace(getSetting(settingBackupScheduleInterval))) + keep, _ := strconv.Atoi(strings.TrimSpace(getSetting(settingBackupScheduleKeep))) + return backupScheduleConfig{ + Enabled: getSetting(settingBackupScheduleEnabled) == "1", + Dir: strings.TrimSpace(getSetting(settingBackupScheduleDir)), + IntervalHours: interval, + Keep: keep, + } +} + +func (s *Server) backupScheduleConfig() backupScheduleConfig { + return loadBackupScheduleConfig(func(k string) string { return mustGetSetting(s.DB, k) }) +} + +// StartBackupScheduler runs the scheduled-backup loop until ctx is cancelled. +// It wakes every 10 minutes and backs up when the newest existing backup is +// older than the configured interval, so interval/dir changes take effect +// without a restart and a restart doesn't force an immediate backup. +func (s *Server) StartBackupScheduler(ctx context.Context) { + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + // A short initial delay so startup isn't competing with the first backup. + select { + case <-ctx.Done(): + return + case <-time.After(1 * time.Minute): + } + for { + s.maybeRunScheduledBackup() + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() +} + +// maybeRunScheduledBackup runs a backup if one is due per the configured interval. +func (s *Server) maybeRunScheduledBackup() { + cfg := s.backupScheduleConfig() + if !cfg.Enabled || cfg.Dir == "" || cfg.IntervalHours <= 0 { + return + } + if appdb.BackendOf(s.DB) == appdb.BackendMariaDB { + return + } + newest := newestBackupModTime(cfg.Dir) + if !newest.IsZero() && time.Since(newest) < time.Duration(cfg.IntervalHours)*time.Hour { + return + } + if _, err := s.runScheduledBackupOnce(cfg.Dir, cfg.Keep); err != nil { + log.Printf("scheduled backup: %v", err) + _ = models.LogActivity(s.DB, 0, "system", "backup_scheduled_failed", cfg.Dir, err.Error(), false) + } +} + +// runScheduledBackupOnce writes one VACUUM INTO snapshot into dir and prunes to +// the newest `keep` backups. Returns the written file path. SQLite only. +func (s *Server) runScheduledBackupOnce(dir string, keep int) (string, error) { + if appdb.BackendOf(s.DB) == appdb.BackendMariaDB { + return "", fmt.Errorf("scheduled backups are SQLite-only; MariaDB is managed by your database server") + } + dir = strings.TrimSpace(dir) + if dir == "" { + return "", fmt.Errorf("no backup directory configured") + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", fmt.Errorf("create backup dir: %w", err) + } + ts := time.Now().Format("20060102-150405") + path := filepath.Join(dir, backupFilePrefix+ts+backupFileSuffix) + if _, err := s.DB.Exec("VACUUM INTO ?", path); err != nil { + return "", fmt.Errorf("vacuum into %s: %w", path, err) + } + if err := os.Chmod(path, 0o640); err != nil { + log.Printf("scheduled backup: chmod %s: %v", path, err) + } + pruneBackups(dir, keep) + _ = models.LogActivity(s.DB, 0, "system", "backup_scheduled", path, fmt.Sprintf("keep=%d", keep), true) + return path, nil +} + +// postBackupRun runs one backup immediately (the "Back up now" button). Admin +// only. Uses the directory/keep submitted with the form (so unsaved edits are +// honoured), falling back to the stored settings. +func (s *Server) postBackupRun(w http.ResponseWriter, r *http.Request) { + u := s.currentUser(r) + if u == nil || u.Role != models.RoleAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + cfg := s.backupScheduleConfig() + dir := strings.TrimSpace(r.FormValue(settingBackupScheduleDir)) + if dir == "" { + dir = cfg.Dir + } + keep := cfg.Keep + if k, err := strconv.Atoi(strings.TrimSpace(r.FormValue(settingBackupScheduleKeep))); err == nil { + keep = k + } + if dir == "" { + http.Redirect(w, r, "/settings/backup?backuperr="+url.QueryEscape("set a backup directory first"), http.StatusSeeOther) + return + } + path, err := s.runScheduledBackupOnce(dir, keep) + if err != nil { + http.Redirect(w, r, "/settings/backup?backuperr="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/settings/backup?backupok="+url.QueryEscape(filepath.Base(path)), http.StatusSeeOther) +} + +// backupFiles returns the backup files in dir, sorted newest-first by mod time. +func backupFiles(dir string) []os.DirEntry { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []os.DirEntry + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, backupFilePrefix) && strings.HasSuffix(name, backupFileSuffix) { + out = append(out, e) + } + } + sort.Slice(out, func(i, j int) bool { + ii, _ := out[i].Info() + ji, _ := out[j].Info() + if ii == nil || ji == nil { + return false + } + return ii.ModTime().After(ji.ModTime()) + }) + return out +} + +// newestBackupModTime returns the mod time of the most recent backup in dir, or +// the zero time if there are none. +func newestBackupModTime(dir string) time.Time { + files := backupFiles(dir) + if len(files) == 0 { + return time.Time{} + } + info, err := files[0].Info() + if err != nil { + return time.Time{} + } + return info.ModTime() +} + +// pruneBackups deletes all but the newest `keep` backups in dir. keep <= 0 +// keeps everything. +func pruneBackups(dir string, keep int) { + if keep <= 0 { + return + } + files := backupFiles(dir) + if keep >= len(files) { + return + } + for _, e := range files[keep:] { + _ = os.Remove(filepath.Join(dir, e.Name())) + } +} diff --git a/internal/server/backup_schedule_test.go b/internal/server/backup_schedule_test.go new file mode 100644 index 0000000..6312a65 --- /dev/null +++ b/internal/server/backup_schedule_test.go @@ -0,0 +1,108 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + "time" + + appdb "github.com/X4Applegate/caddyui/internal/db" +) + +func TestLoadBackupScheduleConfig(t *testing.T) { + vals := map[string]string{ + settingBackupScheduleEnabled: "1", + settingBackupScheduleDir: " /backups ", + settingBackupScheduleInterval: "24", + settingBackupScheduleKeep: "7", + } + cfg := loadBackupScheduleConfig(func(k string) string { return vals[k] }) + if !cfg.Enabled || cfg.Dir != "/backups" || cfg.IntervalHours != 24 || cfg.Keep != 7 { + t.Fatalf("cfg = %+v", cfg) + } + // Disabled + junk numbers default to zero. + cfg = loadBackupScheduleConfig(func(k string) string { + if k == settingBackupScheduleInterval { + return "nope" + } + return "" + }) + if cfg.Enabled || cfg.Dir != "" || cfg.IntervalHours != 0 || cfg.Keep != 0 { + t.Fatalf("empty cfg = %+v", cfg) + } +} + +func TestPruneBackupsKeepsNewest(t *testing.T) { + dir := t.TempDir() + base := time.Now().Add(-10 * time.Hour) + // Create 5 backups with increasing mod times: bk0 oldest … bk4 newest. + for i := 0; i < 5; i++ { + p := filepath.Join(dir, backupFilePrefix+string(rune('a'+i))+backupFileSuffix) + if err := os.WriteFile(p, []byte("x"), 0o640); err != nil { + t.Fatal(err) + } + mt := base.Add(time.Duration(i) * time.Hour) + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } + // A non-backup file must never be touched. + other := filepath.Join(dir, "keepme.txt") + _ = os.WriteFile(other, []byte("x"), 0o640) + + pruneBackups(dir, 2) + + remaining := backupFiles(dir) + if len(remaining) != 2 { + t.Fatalf("kept %d backups, want 2", len(remaining)) + } + // The two newest (e, d) must survive; ordered newest-first. + if remaining[0].Name() != backupFilePrefix+"e"+backupFileSuffix || remaining[1].Name() != backupFilePrefix+"d"+backupFileSuffix { + t.Fatalf("kept the wrong files: %s, %s", remaining[0].Name(), remaining[1].Name()) + } + if _, err := os.Stat(other); err != nil { + t.Fatalf("non-backup file was removed: %v", err) + } + + // keep=0 keeps everything. + pruneBackups(dir, 0) + if len(backupFiles(dir)) != 2 { + t.Fatalf("keep=0 should not prune") + } +} + +func TestRunScheduledBackupOnce(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "caddyui.db") + conn, err := appdb.Open(dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + s := &Server{DB: conn, DBPath: dbPath} + + dir := filepath.Join(t.TempDir(), "backups") + // Three backups, keep 2 → the oldest is pruned. + var last string + for i := 0; i < 3; i++ { + p, err := s.runScheduledBackupOnce(dir, 2) + if err != nil { + t.Fatal(err) + } + last = p + if fi, err := os.Stat(p); err != nil || fi.Size() == 0 { + t.Fatalf("backup %q not written: %v", p, err) + } + time.Sleep(1100 * time.Millisecond) // distinct second-resolution timestamps + } + files := backupFiles(dir) + if len(files) != 2 { + t.Fatalf("kept %d backups, want 2", len(files)) + } + if filepath.Base(last) != files[0].Name() { + t.Fatalf("newest backup %q not first in %v", filepath.Base(last), files[0].Name()) + } + // newestBackupModTime should be recent. + if time.Since(newestBackupModTime(dir)) > time.Minute { + t.Fatalf("newestBackupModTime looks stale") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 0a8bf66..4132c5a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -850,6 +850,7 @@ func (s *Server) Routes() http.Handler { r.Post("/settings", s.postSettings) r.Post("/settings/analytics/prune", s.pruneAnalyticsHandler) // v2.43.0 r.Post("/settings/analytics/vacuum", s.vacuumAnalyticsHandler) // v2.43.0 + r.Post("/settings/backup/run", s.postBackupRun) // v2.52.0 (issue #104) r.Post("/settings/test-webhook", s.postTestWebhook) r.Post("/settings/test-email", s.postTestEmail) r.Post("/settings/test-crowdsec", s.postTestCrowdSec) @@ -14624,8 +14625,15 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { "DNSVerifyResolver": mustGetSetting(s.DB, settingDNSVerifyResolver), // issue #100: fleet-wide IP blocklist. "GlobalIPBlocklist": mustGetSetting(s.DB, settingGlobalIPBlocklist), - "Success": success, - "ClearedName": clearedName, + // issue #104: scheduled backups. + "BackupScheduleEnabled": mustGetSetting(s.DB, settingBackupScheduleEnabled) == "1", + "BackupScheduleDir": mustGetSetting(s.DB, settingBackupScheduleDir), + "BackupScheduleInterval": mustGetSetting(s.DB, settingBackupScheduleInterval), + "BackupScheduleKeep": mustGetSetting(s.DB, settingBackupScheduleKeep), + "BackupOK": r.URL.Query().Get("backupok"), + "BackupErr": r.URL.Query().Get("backuperr"), + "Success": success, + "ClearedName": clearedName, // v2.7.0: analytics card "AnalyticsEnabled": analyticsCfg.Enabled, "ExpectationsAutoRollback": expectationsAutoRollbackEnabled(s), // v2.38.0 @@ -14883,14 +14891,23 @@ func (s *Server) postSettings(w http.ResponseWriter, r *http.Request) { // detect whether we need to retarget every managed DNS record. oldServerIP := s.serverIP() + backupSchedEnabled := "0" + if r.FormValue("backup_schedule_enabled") == "on" { + backupSchedEnabled = "1" + } kv := map[string]string{ - settingDNSVerifyResolver: strings.TrimSpace(r.FormValue("dns_verify_resolver")), // issue #98 - settingGlobalIPBlocklist: strings.TrimSpace(r.FormValue("global_ip_blocklist")), // issue #100 - settingNotifyWebhookURL: webhookURL, - settingNotifyWebhookSecret: webhookSecret, - settingNotifyNtfyURL: ntfyURL, // v2.12.51 - settingNotifyDaysBefore: strconv.Itoa(daysBefore), - settingSMTPHost: smtpHost, + settingDNSVerifyResolver: strings.TrimSpace(r.FormValue("dns_verify_resolver")), // issue #98 + settingGlobalIPBlocklist: strings.TrimSpace(r.FormValue("global_ip_blocklist")), // issue #100 + // issue #104: scheduled off-host backups. + settingBackupScheduleEnabled: backupSchedEnabled, + settingBackupScheduleDir: strings.TrimSpace(r.FormValue("backup_schedule_dir")), + settingBackupScheduleInterval: strings.TrimSpace(r.FormValue("backup_schedule_interval_hours")), + settingBackupScheduleKeep: strings.TrimSpace(r.FormValue("backup_schedule_keep")), + settingNotifyWebhookURL: webhookURL, + settingNotifyWebhookSecret: webhookSecret, + settingNotifyNtfyURL: ntfyURL, // v2.12.51 + settingNotifyDaysBefore: strconv.Itoa(daysBefore), + settingSMTPHost: smtpHost, // v2.11.15: AI assistant settings. settingAIEnabled: func() string { for _, v := range r.PostForm["ai_enabled"] { diff --git a/internal/server/settings_sections.go b/internal/server/settings_sections.go index e25d827..7b22470 100644 --- a/internal/server/settings_sections.go +++ b/internal/server/settings_sections.go @@ -147,6 +147,11 @@ var settingsKeySection = map[string]string{ settingAIOpenAIModel: "ai", settingAIOpenAIAPIKey: "ai", settingAISystemPrompt: "ai", + + settingBackupScheduleEnabled: "backup", + settingBackupScheduleDir: "backup", + settingBackupScheduleInterval: "backup", + settingBackupScheduleKeep: "backup", } // settingsSectionSyncsCaddy reports whether saving a page can change the diff --git a/web/templates/settings.html b/web/templates/settings.html index 0dab995..33487d2 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -1334,11 +1334,41 @@

Settings {{else}} + {{if .BackupOK}}
Backup written: {{.BackupOK}}
{{end}} + {{if .BackupErr}}
Backup failed: {{.BackupErr}}
{{end}}

Download a consistent snapshot of the CaddyUI database. To restore, stop the server, replace the caddyui.db file, and restart.

Download Backup + + {{/* issue #104: scheduled off-host backups */}} +
+ +

Write a snapshot to a directory on an interval and keep the newest N. Point the directory at a mounted volume, network share, or object-store gateway to keep copies off the host.

+
+ + +
+
+ Every + + hours, keep the newest + + (0 = keep all). +
+
+ + +
+
{{end}} From d4ffdb860b2f3ed886f4d6c05bae0734e541d869 Mon Sep 17 00:00:00 2001 From: X4Applegate Date: Thu, 17 Sep 2026 16:54:47 -0700 Subject: [PATCH 2/3] fix: read backup dir only from saved settings (CodeQL path-injection) CodeQL flagged the 'Back up now' handler because it took the backup directory from the request form and passed it into filesystem operations (path injection, 4 high alerts). The value is admin-only, but the clean fix is to run backups using only the saved setting (a DB read, not the request), so no request path reaches the filesystem. 'Back up now' now uses the saved directory (Save first). --- internal/server/backup_schedule.go | 19 ++++++------------- web/templates/settings.html | 1 + 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/internal/server/backup_schedule.go b/internal/server/backup_schedule.go index fc36b15..f474c01 100644 --- a/internal/server/backup_schedule.go +++ b/internal/server/backup_schedule.go @@ -125,8 +125,9 @@ func (s *Server) runScheduledBackupOnce(dir string, keep int) (string, error) { } // postBackupRun runs one backup immediately (the "Back up now" button). Admin -// only. Uses the directory/keep submitted with the form (so unsaved edits are -// honoured), falling back to the stored settings. +// only. Uses the *saved* backup directory/retention — not values from the +// request — so an untrusted request path never reaches the filesystem (Save +// the directory first, then Back up now). func (s *Server) postBackupRun(w http.ResponseWriter, r *http.Request) { u := s.currentUser(r) if u == nil || u.Role != models.RoleAdmin { @@ -134,19 +135,11 @@ func (s *Server) postBackupRun(w http.ResponseWriter, r *http.Request) { return } cfg := s.backupScheduleConfig() - dir := strings.TrimSpace(r.FormValue(settingBackupScheduleDir)) - if dir == "" { - dir = cfg.Dir - } - keep := cfg.Keep - if k, err := strconv.Atoi(strings.TrimSpace(r.FormValue(settingBackupScheduleKeep))); err == nil { - keep = k - } - if dir == "" { - http.Redirect(w, r, "/settings/backup?backuperr="+url.QueryEscape("set a backup directory first"), http.StatusSeeOther) + if cfg.Dir == "" { + http.Redirect(w, r, "/settings/backup?backuperr="+url.QueryEscape("set a backup directory and Save first"), http.StatusSeeOther) return } - path, err := s.runScheduledBackupOnce(dir, keep) + path, err := s.runScheduledBackupOnce(cfg.Dir, cfg.Keep) if err != nil { http.Redirect(w, r, "/settings/backup?backuperr="+url.QueryEscape(err.Error()), http.StatusSeeOther) return diff --git a/web/templates/settings.html b/web/templates/settings.html index 33487d2..ae4e2a6 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -1368,6 +1368,7 @@

Settings Save settings +

Back up now uses the saved directory — Save first if you just changed it.

{{end}} From d6643889d3d050f38c4e232aede6a0439e6419a3 Mon Sep 17 00:00:00 2001 From: X4Applegate Date: Thu, 17 Sep 2026 16:59:29 -0700 Subject: [PATCH 3/3] improve: millisecond backup timestamps to avoid same-second collision (review on #105) --- internal/server/backup_schedule.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/server/backup_schedule.go b/internal/server/backup_schedule.go index f474c01..d343d63 100644 --- a/internal/server/backup_schedule.go +++ b/internal/server/backup_schedule.go @@ -111,7 +111,10 @@ func (s *Server) runScheduledBackupOnce(dir string, keep int) (string, error) { if err := os.MkdirAll(dir, 0o750); err != nil { return "", fmt.Errorf("create backup dir: %w", err) } - ts := time.Now().Format("20060102-150405") + // Millisecond resolution so a scheduled run and a manual "Back up now" in + // the same second can't collide on the same path (VACUUM INTO fails if the + // target already exists). + ts := time.Now().Format("20060102-150405.000") path := filepath.Join(dir, backupFilePrefix+ts+backupFileSuffix) if _, err := s.DB.Exec("VACUUM INTO ?", path); err != nil { return "", fmt.Errorf("vacuum into %s: %w", path, err)