diff --git a/app/routers/deployments.py b/app/routers/deployments.py
index 2f3f072..47a378f 100644
--- a/app/routers/deployments.py
+++ b/app/routers/deployments.py
@@ -2,7 +2,7 @@
import logging
-from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
+from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db
@@ -19,6 +19,14 @@ router = APIRouter()
async def manual_deploy(
customer_id: int,
background_tasks: BackgroundTasks,
+ keep_data: bool = Query(
+ False,
+ description=(
+ "If True, preserve existing NetBird data (database, keys, peers). "
+ "Containers are recreated without wiping the instance directory. "
+ "If False (default), the instance is fully removed and redeployed from scratch."
+ ),
+ ),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
@@ -29,6 +37,7 @@ async def manual_deploy(
Args:
customer_id: Customer ID.
+ keep_data: Whether to preserve existing NetBird data.
Returns:
Acknowledgement dict.
@@ -40,12 +49,12 @@ async def manual_deploy(
customer.status = "deploying"
db.commit()
- async def _deploy_bg(cid: int) -> None:
+ async def _deploy_bg(cid: int, keep: bool) -> None:
bg_db = SessionLocal()
try:
- # Remove existing deployment if present
existing = bg_db.query(Deployment).filter(Deployment.customer_id == cid).first()
- if existing:
+ if existing and not keep:
+ # Full redeploy: remove everything first
await netbird_service.undeploy_customer(bg_db, cid)
await netbird_service.deploy_customer(bg_db, cid)
except Exception:
@@ -53,7 +62,7 @@ async def manual_deploy(
finally:
bg_db.close()
- background_tasks.add_task(_deploy_bg, customer_id)
+ background_tasks.add_task(_deploy_bg, customer_id, keep_data)
return {"message": "Deployment started in background.", "status": "deploying"}
diff --git a/app/services/netbird_service.py b/app/services/netbird_service.py
index a1e8bdf..7f40783 100644
--- a/app/services/netbird_service.py
+++ b/app/services/netbird_service.py
@@ -264,10 +264,12 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
_log_action(db, customer_id, "deploy", "info",
"Auto-setup failed — admin must complete setup manually.")
- # Step 9: Create NPM proxy host (production only)
- npm_proxy_id = None
- npm_stream_id = None
- if not local_mode:
+ # Step 9: Create NPM proxy host (production only).
+ # If an existing deployment already has an NPM proxy, reuse it — this happens
+ # when keep_data=True was passed and undeploy_customer was NOT called beforehand.
+ npm_proxy_id = existing_deployment.npm_proxy_id if existing_deployment else None
+ npm_stream_id = existing_deployment.npm_stream_id if existing_deployment else None
+ if not local_mode and not npm_proxy_id:
forward_host = npm_service._get_forward_host()
npm_result = await npm_service.create_proxy_host(
api_url=config.npm_api_url,
@@ -304,9 +306,14 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
"SSL certificate not created automatically. "
"Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.",
)
+ elif npm_proxy_id and not local_mode:
+ _log_action(db, customer_id, "deploy", "info",
+ f"Reusing existing NPM proxy (ID {npm_proxy_id}) — data preserved.")
- # Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment)
- if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip:
+ # Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment).
+ # Skip if an existing deployment is being kept (DNS record already exists).
+ if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip \
+ and not existing_deployment:
try:
dns_result = await dns_service.create_dns_record(customer.subdomain, config)
if dns_result["ok"]:
diff --git a/static/index.html b/static/index.html
index 4c236c7..4fbe6c5 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1267,6 +1267,49 @@
+
+
+
+
+
+
Redeploy Customer
+
+
+
+
How shouldbe redeployed?
+
+
+
+
+
+
Keep Data
+
Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.
+
+
+
+
+
+
+
+
Fresh Deploy
+
All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/js/app.js b/static/js/app.js
index e19de23..bd1405f 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -667,9 +667,13 @@ async function confirmDeleteCustomer() {
}
// ---------------------------------------------------------------------------
-// Customer Actions (start/stop/restart)
+// Customer Actions (start/stop/restart/deploy)
// ---------------------------------------------------------------------------
async function customerAction(id, action) {
+ if (action === 'deploy') {
+ showRedeployModal(id);
+ return;
+ }
try {
await api('POST', `/customers/${id}/${action}`);
if (currentPage === 'dashboard') loadCustomers();
@@ -679,6 +683,26 @@ async function customerAction(id, action) {
}
}
+function showRedeployModal(id) {
+ const row = document.querySelector(`tr[data-customer-id="${id}"]`);
+ const name = row ? row.querySelector('td')?.textContent?.trim() : `#${id}`;
+ document.getElementById('redeploy-customer-id').value = id;
+ document.getElementById('redeploy-customer-name').textContent = name;
+ new bootstrap.Modal(document.getElementById('redeploy-modal')).show();
+}
+
+async function confirmRedeploy(keepData) {
+ const id = document.getElementById('redeploy-customer-id').value;
+ bootstrap.Modal.getInstance(document.getElementById('redeploy-modal'))?.hide();
+ try {
+ await api('POST', `/customers/${id}/deploy?keep_data=${keepData}`);
+ if (currentPage === 'dashboard') loadCustomers();
+ if (currentCustomerId == id) viewCustomer(id);
+ } catch (err) {
+ alert(t('errors.actionFailed', { action: 'deploy', error: err.message }));
+ }
+}
+
// ---------------------------------------------------------------------------
// Customer Detail
// ---------------------------------------------------------------------------
diff --git a/static/lang/de.json b/static/lang/de.json
index 71fe9af..b1bfb37 100644
--- a/static/lang/de.json
+++ b/static/lang/de.json
@@ -354,6 +354,17 @@
"saveAndDeploy": "Speichern & Bereitstellen",
"saveChanges": "Änderungen speichern"
},
+ "redeployModal": {
+ "title": "Kunde neu bereitstellen",
+ "intro": "Wie soll",
+ "intro2": "neu bereitgestellt werden?",
+ "keepTitle": "Daten behalten",
+ "keepDesc": "Container werden gestoppt und neu gestartet. Die NetBird-Datenbank, Peer-Konfigurationen und Verschlüsselungsschlüssel bleiben erhalten. Verwenden Sie dies nach einer Konfigurationsänderung oder einem Image-Update.",
+ "keepNote": "Peers bleiben nach dem Neustart verbunden.",
+ "freshTitle": "Neu aufsetzen",
+ "freshDesc": "Alle bestehenden Daten werden gelöscht — Container, Volumes, Konfigurationsdateien und die NetBird-Datenbank. Eine komplett neue Instanz wird erstellt. Alle Peers müssen sich neu registrieren.",
+ "freshNote": "Alle Peer-Daten gehen verloren. Kann nicht rückgängig gemacht werden."
+ },
"deleteModal": {
"title": "Löschen bestätigen",
"confirmText": "Möchten Sie den Kunden wirklich löschen:",
diff --git a/static/lang/en.json b/static/lang/en.json
index d60c777..3ed6b73 100644
--- a/static/lang/en.json
+++ b/static/lang/en.json
@@ -107,6 +107,17 @@
"saveAndDeploy": "Save & Deploy",
"saveChanges": "Save Changes"
},
+ "redeployModal": {
+ "title": "Redeploy Customer",
+ "intro": "How should",
+ "intro2": "be redeployed?",
+ "keepTitle": "Keep Data",
+ "keepDesc": "Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.",
+ "keepNote": "Peers stay connected after restart.",
+ "freshTitle": "Fresh Deploy",
+ "freshDesc": "All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.",
+ "freshNote": "All peer data is lost. Cannot be undone."
+ },
"deleteModal": {
"title": "Confirm Deletion",
"confirmText": "Are you sure you want to delete customer",