diff --git a/configs/backup/backup_general b/configs/backup/backup_general
index ba519a3..019f120 100755
--- a/configs/backup/backup_general
+++ b/configs/backup/backup_general
@@ -2,6 +2,7 @@
# Backup General - Scheduling
# @icon ๐พ
# ================================================================================
+CFG_BACKUP_CONNECT_ENDPOINT= # LibrePortal Connect - Base URL of the hosted backup service. Empty means Connect is not available yet and the WebUI offers it greyed out; setting it is all that turns it on. **ADVANCED**
CFG_BACKUP_MODE=automatic # Backups - Automatic runs them on the schedule below; Manual means you start them yourself from the Backup page [automatic:Automatic|manual:Manual]
CFG_BACKUP_CRONTAB_APP="0 5 * * *" # App Backup Schedule - Crontab schedule for application backups
CFG_BACKUP_DASHBOARD_REFRESH_INTERVAL=30 # Dashboard Refresh Interval - Minutes between routine restic pulls that refresh the Backups dashboard
diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
index d251c35..9836123 100755
--- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js
+++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
@@ -46,6 +46,8 @@ class SetupWizard {
// the install already has, so the default one is shown and editable rather
// than being a thing you discover later on the Backup page.
this.backupLocations = [];
+ // Set from the locations feed; assumed off until it says otherwise.
+ this.connect = { available: false, endpoint: '' };
// .lpapp exports found at the path the user gave, and which to import.
this.importResults = [];
this.importSelected = [];
@@ -734,6 +736,10 @@ class SetupWizard {
const r = await fetch('/data/backup/generated/locations.json', { cache: 'no-store' });
const d = await r.json();
const list = Array.isArray(d) ? d : (d.locations || []);
+ // Whether LibrePortal Connect can be chosen. Reported by the host rather
+ // than decided here, so the day the service exists this is a config value
+ // and the UI needs no change.
+ this.connect = (d && d.connect) || { available: false, endpoint: '' };
this.backupLocations = list.map(l => ({
idx: l.idx,
name: l.name || `Location ${l.idx}`,
@@ -877,6 +883,7 @@ class SetupWizard {
+
`)}
@@ -908,6 +915,21 @@ class SetupWizard {
${field('bk-s3secret', 'S3 Secret Key', 'Stored where only LibrePortal can read it.', secret('bk-s3secret'))}
+
+ ${this.connect.available ? `
+ ${field('bk-cn-token', 'Device code', 'From your LibrePortal Connect account. Pairs this machine to your storage there.',
+ text('bk-cn-token', '', 'XXXX-XXXX-XXXX'))}
+
Backups are encrypted here before they leave. Connect stores the result
+ and never holds the key, so nobody there can open them \u2014 keep the repository password somewhere
+ other than this machine.
+ ` : `
+
The hosted option: somewhere off this machine to keep backups without
+ running a server yourself. Not available yet.
+
Nothing waits on it \u2014 SFTP and S3 above do the same job today, and
+ always will. Every hosted service has a free equivalent in the open code.
+ `}
+
+
${field('bk-b2uri', 'Bucket', 'For example b2:my-bucket.', text('bk-b2uri', loc.uri, 'b2:my-bucket'))}
${field('bk-b2id', 'B2 Account ID', '', text('bk-b2id', loc.b2_account_id))}
@@ -954,6 +976,13 @@ class SetupWizard {
next.uri = v('bk-s3uri');
next.s3_access_key = v('bk-s3key');
if (!await stash('bk-s3secret', 's3_secret_ref')) return;
+ } else if (type === 'connect') {
+ // Refuse rather than save something that cannot work. The option
+ // is disabled in the list, but a type can also arrive from an
+ // existing location, so the guard belongs here too.
+ if (!this.connect.available) return;
+ next.uri = this.connect.endpoint;
+ if (!await stash('bk-cn-token', 'connect_token_ref')) return;
} else if (type === 'b2') {
next.uri = v('bk-b2uri');
next.b2_account_id = v('bk-b2id');
diff --git a/docs/roadmap/connect-backup-destination.md b/docs/roadmap/connect-backup-destination.md
new file mode 100644
index 0000000..e2d1358
--- /dev/null
+++ b/docs/roadmap/connect-backup-destination.md
@@ -0,0 +1,101 @@
+# LibrePortal Connect as a backup destination (design)
+
+**Status:** client side built, greyed out. There is no service yet. ยท
+**Scope:** what Connect has to be for backups, and what already exists ยท
+**Origin:** "maybe add a section of adding LibrePortal's connect service" (2026-08-28)
+
+---
+
+## 0. What Connect is promised to be
+
+From `docs/guide/promise.md`, which is where this starts and what it has to obey:
+
+> **LibrePortal Connect** โ optional services for the tricky parts, like reaching
+> your server from your phone or keeping off-site backups. We work like a
+> courier carrying a sealed box: we move and store your data, but it stays
+> locked and *you* hold the only key.
+
+and:
+
+> Every paid service has a free, self-hostable equivalent in the open code.
+
+Both of those are load-bearing here, not marketing. The first says the service
+must never be able to read a backup. The second says shipping Connect must not
+make anything worse for someone who never uses it.
+
+## 1. What already exists
+
+Almost all of it, because a Connect destination is not a new kind of thing.
+
+| Piece | Status |
+|---|---|
+| Client-side encryption with a key the server never sees | โ restic, always on |
+| A backend that stores packs over HTTP | โ `rest` location type |
+| Per-location URI, engine, retention, append-only | โ the location model |
+| A password the user keeps, escrowed locally | โ `passwords.txt`, 0600 |
+| Somewhere to enter a credential without it leaking | โ the secret channel |
+| A destination list in the wizard to hang it off | โ the Backups step |
+| **The service** | โ does not exist |
+
+So the client half is a REST repository with credentials in the URI. That is why
+`connect` resolves exactly like `rest` in `resticLocationUri` โ it *is* one.
+
+It is a separate TYPE only so the UI can tell "LibrePortal Connect" apart from
+"a REST server I run myself", which are identical on disk. Anyone who wants the
+second can have it today, free, which is the promise's litmus test passing.
+
+## 2. Shape of a Connect destination
+
+```
+CFG_BACKUP_LOC__TYPE=connect
+CFG_BACKUP_LOC__URI=rest:https://:@connect.libreportal.org/v1//
+CFG_BACKUP_LOC__PASSWORD=
+```
+
+The URI carries the device credentials; the PASSWORD is the encryption key and
+stays here. The service holds packs it cannot open. That is the sealed box,
+implemented rather than asserted.
+
+The device code the user pastes is a credential, so it travels through the
+secret channel (`scripts/webui/webui_secret.sh`) as a reference โ never in the
+wizard payload, which is base64'd into a world-readable task file.
+
+## 3. The not-yet-available state
+
+`CFG_BACKUP_CONNECT_ENDPOINT` decides it, and it is empty.
+
+The locations feed reports `connect: {available, endpoint}`, and the wizard
+renders from that: the option is present but disabled and labelled "not
+available yet", and its panel says what it will be and โ more importantly โ
+that SFTP and S3 do the same job today.
+
+Deliberately data-driven: **the day the service exists, setting that one value
+turns it on.** No release, no code change. Verified by setting it and watching
+the option go from disabled to a working device-code field, then unsetting it.
+
+Showing a disabled option at all is a judgement call: it advertises something
+that cannot be bought. It earns its place by answering the question the step
+otherwise raises โ "is off-site a thing this can do?" โ and by pointing at the
+free answer in the same breath.
+
+## 4. What the service still has to provide
+
+None of this is client work:
+
+- accounts, and a device-code flow that yields a per-device credential
+- a restic-compatible REST endpoint per repository, scoped to that device
+- quota and retention policy the client can read, so the UI can say what is left
+- revocation, so a lost machine can be cut off without rotating the repo password
+
+## 5. Open questions
+
+1. **Per-device or per-account credentials?** Per-device is what allows
+ revocation without re-keying every machine, but it means the service tracks
+ devices โ which is state about the user it does not otherwise need.
+2. **Does Connect ever hold the repository password?** It must not. But then a
+ user who loses it loses the backup, and a hosted service is exactly what
+ someone buys to avoid that responsibility. The honest answer is that it
+ cannot be both; the escrow file and saying so plainly is the current answer.
+3. **What does the wizard do about quota before an account exists?** Nothing to
+ show until the endpoint answers, so the step stays silent rather than
+ guessing.
diff --git a/scripts/backup/engine/restic_env.sh b/scripts/backup/engine/restic_env.sh
index d691e10..dc06fbd 100644
--- a/scripts/backup/engine/restic_env.sh
+++ b/scripts/backup/engine/restic_env.sh
@@ -88,7 +88,11 @@ resticLocationUri()
path=$(resticLocationField "$idx" SSH_PATH)
echo "sftp:${user}@${host}:${path}"
;;
- rest)
+ rest|connect)
+ # Connect is a REST repository on storage we do not run. The URI
+ # carries the device credentials; the repo PASSWORD stays here and
+ # is what the data is encrypted with, so the far side only ever
+ # holds packs it cannot open.
resticLocationField "$idx" URI
;;
s3|b2|gs|azure|rclone)
diff --git a/scripts/backup/locations/location_add.sh b/scripts/backup/locations/location_add.sh
index 88c6fb7..eacf5a3 100644
--- a/scripts/backup/locations/location_add.sh
+++ b/scripts/backup/locations/location_add.sh
@@ -11,7 +11,11 @@ locationAdd()
fi
case "$type" in
- local|sftp|rest|s3|b2|gs|azure|rclone) ;;
+ # connect resolves exactly like rest โ it IS a restic REST repository.
+ # It is its own type only so the UI can tell a LibrePortal Connect
+ # destination apart from a REST server someone runs themselves, which
+ # otherwise look identical on disk.
+ local|sftp|rest|connect|s3|b2|gs|azure|rclone) ;;
*) isError "Unsupported location type: $type"; return 1 ;;
esac
diff --git a/scripts/webui/data/generators/backup/webui_backup_locations.sh b/scripts/webui/data/generators/backup/webui_backup_locations.sh
index f4fc7fa..2b3ebaa 100644
--- a/scripts/webui/data/generators/backup/webui_backup_locations.sh
+++ b/scripts/webui/data/generators/backup/webui_backup_locations.sh
@@ -17,6 +17,19 @@ webuiGenerateBackupLocations()
local content="{"
content+="\"generated_at\":\"$(date -Iseconds)\","
+
+ # Whether LibrePortal Connect can be chosen at all. Reported as data rather
+ # than decided in the UI, so the day the service exists this becomes a
+ # config value and nothing ships to enable it. Empty endpoint = not yet.
+ local _connect_ep="${CFG_BACKUP_CONNECT_ENDPOINT:-}"
+ content+="\"connect\":{"
+ content+="\"available\":$( [[ -n "$_connect_ep" ]] && echo true || echo false ),"
+ # A URL from config; strip the two characters that would break the JSON
+ # rather than lean on an escaper this generator does not have.
+ _connect_ep="${_connect_ep//\\/}"
+ _connect_ep="${_connect_ep//\"/}"
+ content+="\"endpoint\":\"${_connect_ep}\""
+ content+="},"
content+="\"locations\":["
local first=true