[misc] Move enterprise setup to Traefik and harden migration (#7042)

This commit is contained in:
Bethuel Mmbaga
2026-08-05 19:38:02 +03:00
committed by GitHub
parent 19a6cedfff
commit 40ac64bb4e
2 changed files with 419 additions and 89 deletions

View File

@@ -11,6 +11,10 @@ SED_STRIP_PADDING='s/=//g'
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
# Static IP for Traefik inside the compose bridge network. The management
# server trusts X-Forwarded-* headers from this address only.
TRAEFIK_IP="172.30.0.10"
check_docker_compose() {
if command -v docker-compose &> /dev/null; then
echo "docker-compose"
@@ -80,7 +84,7 @@ read_nb_domain() {
if ! check_domain_resolves "$value"; then
echo "" > /dev/stderr
echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr
echo "Caddy will not be able to issue TLS certificates until it does." > /dev/stderr
echo "Traefik will not be able to issue TLS certificates until it does." > /dev/stderr
local confirm=""
echo -n "Continue anyway? [y/N]: " > /dev/stderr
read -r confirm < /dev/tty
@@ -92,6 +96,23 @@ read_nb_domain() {
echo "$value"
}
read_letsencrypt_email() {
if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL:-}" ]]; then
echo "$NETBIRD_LETSENCRYPT_EMAIL"
return
fi
local value=""
echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr
echo -n "Email address: " > /dev/stderr
read -r value < /dev/tty
if [[ -z "$value" ]]; then
echo "Email is required for Let's Encrypt." > /dev/stderr
read_letsencrypt_email
return
fi
echo "$value"
}
read_required() {
local prompt="$1"
local value=""
@@ -204,11 +225,11 @@ init_environment() {
check_openssl
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]] || [[ -f Caddyfile ]]; then
if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then
echo "Generated files already exist in $(pwd)."
echo "If you want to reinitialize the environment, please remove them first:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes"
echo " rm -f .env docker-compose.yml Caddyfile config.yaml"
echo " rm -f .env docker-compose.yml config.yaml"
echo "Be aware this will remove all data from the database."
exit 1
fi
@@ -230,6 +251,9 @@ init_environment() {
echo ""
NETBIRD_DOMAIN=$(read_nb_domain)
echo ""
NETBIRD_LETSENCRYPT_EMAIL=$(read_letsencrypt_email)
echo ""
NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)")
@@ -247,6 +271,7 @@ init_environment() {
echo "Selected:"
echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}"
echo " Domain: ${NETBIRD_DOMAIN}"
echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}"
echo ""
echo "Rendering files into $(pwd) ..."
install -m 600 /dev/null .env
@@ -256,7 +281,6 @@ init_environment() {
if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak
fi
render_caddyfile > Caddyfile
install -m 600 /dev/null config.yaml
render_config_yaml >> config.yaml
@@ -283,7 +307,7 @@ init_environment() {
echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env"
echo ""
echo "Tail logs:"
echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server caddy"
echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik"
}
# ------------------------------------------------------------------
@@ -306,6 +330,11 @@ NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW}
# Domain
NETBIRD_DOMAIN=${NETBIRD_DOMAIN}
# Reverse proxy (Traefik)
NETBIRD_LETSENCRYPT_EMAIL=${NETBIRD_LETSENCRYPT_EMAIL}
NETBIRD_TRAEFIK_TAG=${NETBIRD_TRAEFIK_TAG:-v3.6}
NETBIRD_TRAEFIK_IP=${TRAEFIK_IP}
# Image tags. Default to "latest"
NETBIRD_DASHBOARD_TAG=${NETBIRD_DASHBOARD_TAG:-latest}
NETBIRD_SERVER_TAG=${NETBIRD_SERVER_TAG:-latest}
@@ -378,26 +407,78 @@ EOF
render_compose_common() {
cat <<'EOF'
caddy:
# Reverse proxy with automatic TLS via Let's Encrypt. Routes are declared as
# labels on the services below and picked up through the Docker provider.
traefik:
<<: *default
image: caddy:2
container_name: netbird-caddy
networks: [netbird]
environment:
- CADDY_SECURE_DOMAIN=${NETBIRD_DOMAIN}
image: traefik:${NETBIRD_TRAEFIK_TAG}
container_name: netbird-traefik
networks:
netbird:
ipv4_address: ${NETBIRD_TRAEFIK_IP}
command:
# Logging
- "--log.level=INFO"
- "--accesslog=true"
# Docker provider
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=netbird"
# Entrypoints
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.allowACMEByPass=true"
# readTimeout bounds the whole request, and gRPC streams / relay WebSockets
# never end one; idleTimeout would close the keep-alive connection they
# are reused over. Entrypoint-wide is the only scope Traefik offers here.
# writeTimeout is left alone: it already defaults to 0.
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0"
- "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=0"
# HTTP to HTTPS redirect
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
# Let's Encrypt ACME
- "--certificatesresolvers.letsencrypt.acme.email=${NETBIRD_LETSENCRYPT_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
ports:
- '443:443'
- '443:443/udp'
- '80:80'
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
- /var/run/docker.sock:/var/run/docker.sock:ro
- netbird_traefik_letsencrypt:/letsencrypt
labels:
- traefik.enable=true
# Shared security headers, referenced by every NetBird router below. A
# label-declared middleware only exists while its container runs, so this
# lives on Traefik itself: declaring it on an app container would drop
# every router referencing it whenever that container restarts.
- traefik.http.middlewares.nb-security.headers.stsSeconds=3600
- traefik.http.middlewares.nb-security.headers.stsIncludeSubdomains=true
- traefik.http.middlewares.nb-security.headers.contentTypeNosniff=true
- traefik.http.middlewares.nb-security.headers.browserXssFilter=true
- traefik.http.middlewares.nb-security.headers.referrerPolicy=strict-origin-when-cross-origin
- traefik.http.middlewares.nb-security.headers.customResponseHeaders.X-Frame-Options=SAMEORIGIN
# Empty value strips the header. Only the dashboard's nginx sets one; the
# server emits none. Do not quote it — "" would send a literal Server: "".
- traefik.http.middlewares.nb-security.headers.customResponseHeaders.Server=
dashboard:
<<: *default
image: ghcr.io/netbirdio/dashboard-cloud:${NETBIRD_DASHBOARD_TAG}
container_name: netbird-dashboard
networks: [netbird]
labels:
- traefik.enable=true
# Dashboard catch-all: lowest priority so every route below wins
- traefik.http.routers.netbird-dashboard.rule=Host(`${NETBIRD_DOMAIN}`)
- traefik.http.routers.netbird-dashboard.entrypoints=websecure
- traefik.http.routers.netbird-dashboard.tls=true
- traefik.http.routers.netbird-dashboard.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-dashboard.middlewares=nb-security@docker
- traefik.http.routers.netbird-dashboard.service=dashboard
- traefik.http.routers.netbird-dashboard.priority=1
- traefik.http.services.dashboard.loadbalancer.server.port=80
environment:
- NETBIRD_MGMT_API_ENDPOINT=https://${NETBIRD_DOMAIN}
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${NETBIRD_DOMAIN}
@@ -435,6 +516,28 @@ render_compose_server() {
- netbird_data:/var/lib/netbird
- ./config.yaml:/etc/netbird/config.yaml
command: ["--config", "/etc/netbird/config.yaml"]
labels:
- traefik.enable=true
# Signal + Management gRPC (needs an h2c backend for HTTP/2 cleartext)
- traefik.http.routers.netbird-grpc.rule=Host(`${NETBIRD_DOMAIN}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`))
- traefik.http.routers.netbird-grpc.entrypoints=websecure
- traefik.http.routers.netbird-grpc.tls=true
- traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-grpc.middlewares=nb-security@docker
- traefik.http.routers.netbird-grpc.service=netbird-server-h2c
- traefik.http.routers.netbird-grpc.priority=100
# Relay WebSocket, management API, and the embedded IdP
- traefik.http.routers.netbird-backend.rule=Host(`${NETBIRD_DOMAIN}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))
- traefik.http.routers.netbird-backend.entrypoints=websecure
- traefik.http.routers.netbird-backend.tls=true
- traefik.http.routers.netbird-backend.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-backend.middlewares=nb-security@docker
- traefik.http.routers.netbird-backend.service=netbird-server
- traefik.http.routers.netbird-backend.priority=100
# Services
- traefik.http.services.netbird-server.loadbalancer.server.port=80
- traefik.http.services.netbird-server-h2c.loadbalancer.server.port=80
- traefik.http.services.netbird-server-h2c.loadbalancer.server.scheme=h2c
environment:
- NB_LICENSE_KEY=${NETBIRD_LICENSE_KEY}
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
@@ -497,6 +600,18 @@ render_compose_flow() {
- NB_FLOW_NATS_ENDPOINTS=nats://nats:4222
- NB_FLOW_NATS_STREAM=traffic-events
- NB_FLOW_AUTH_SECRET=${NETBIRD_RELAY_AUTH_SECRET}
labels:
- traefik.enable=true
# Flow receiver gRPC (h2c backend)
- traefik.http.routers.netbird-flow.rule=Host(`${NETBIRD_DOMAIN}`) && PathPrefix(`/flow.FlowService/`)
- traefik.http.routers.netbird-flow.entrypoints=websecure
- traefik.http.routers.netbird-flow.tls=true
- traefik.http.routers.netbird-flow.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-flow.middlewares=nb-security@docker
- traefik.http.routers.netbird-flow.service=netbird-flow-h2c
- traefik.http.routers.netbird-flow.priority=100
- traefik.http.services.netbird-flow-h2c.loadbalancer.server.port=80
- traefik.http.services.netbird-flow-h2c.loadbalancer.server.scheme=h2c
EOF
}
@@ -536,61 +651,16 @@ EOF
fi
cat <<'EOF'
netbird_postgres:
netbird_caddy_data:
netbird_traefik_letsencrypt:
networks:
netbird:
EOF
}
render_caddyfile() {
cat <<'EOF'
{
servers :80,:443 {
protocols h1 h2c h2 h3
}
}
(security_headers) {
header * {
Strict-Transport-Security "max-age=3600; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
-Server
Referrer-Policy strict-origin-when-cross-origin
}
}
:80 {
redir https://{$CADDY_SECURE_DOMAIN}{uri} permanent
}
{$CADDY_SECURE_DOMAIN}:443 {
import security_headers
# Signal (gRPC over h2c)
reverse_proxy /signalexchange.SignalExchange/* h2c://netbird-server:80
# Management (gRPC over h2c + HTTP)
reverse_proxy /management.ManagementService/* h2c://netbird-server:80
reverse_proxy /api/* netbird-server:80
reverse_proxy /ws-proxy/* netbird-server:80
# Embedded IdP (OAuth2 endpoints served by netbird server)
reverse_proxy /oauth2/* netbird-server:80
# Relay (WebSocket multiplexed on the same port)
reverse_proxy /relay* netbird-server:80
EOF
if [[ "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then
cat <<'EOF'
# Flow receiver (gRPC over h2c)
reverse_proxy /flow.FlowService/* h2c://receiver:80
EOF
fi
cat <<'EOF'
# Dashboard
reverse_proxy /* dashboard:80
}
name: netbird
driver: bridge
ipam:
config:
- subnet: 172.30.0.0/24
gateway: 172.30.0.1
EOF
}
@@ -609,7 +679,7 @@ server:
logLevel: "info"
logFile: "console"
# TLS is terminated by Caddy in front; leave this block empty.
# TLS is terminated by Traefik in front; leave this block empty.
tls:
certFile: ""
keyFile: ""
@@ -632,6 +702,16 @@ server:
cliRedirectURIs:
- "http://localhost:53000/"
# Trust X-Forwarded-* only from the Traefik container's static address. Both
# keys must stay in step with the ipv4_address pinned in docker-compose.yml:
# trustedPeers decides whether forwarded headers are read at all, and leaving
# it unset falls back to 0.0.0.0/0.
reverseProxy:
trustedPeers:
- "${TRAEFIK_IP}/32"
trustedHTTPProxies:
- "${TRAEFIK_IP}/32"
store:
engine: "postgres"
dsn: "${POSTGRES_DSN}"

View File

@@ -15,7 +15,11 @@ set -o pipefail
# 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store.
# 3. Traffic flow — add NATS + flow-enricher + flow-receiver.
#
# To revert:
# If any step fails once the stack has been touched, the script rolls itself
# back automatically: generated files are removed, the Postgres volume this run
# created is dropped, and the original deployment is started again.
#
# To revert a successful migration:
# docker compose down
# rm -f docker-compose.override.yml config.yaml.enterprise
# # If Postgres migration was done, also restore the SQLite backup printed
@@ -25,6 +29,15 @@ set -o pipefail
OVERRIDE_FILE="docker-compose.override.yml"
ENTERPRISE_CONFIG_FILE="config.yaml.enterprise"
# Rollback bookkeeping. ROLLBACK_STATE flips to "armed" the moment the script
# starts mutating the deployment, and back to "disarmed" once the migration has
# completed successfully.
ROLLBACK_STATE="disarmed"
ENV_EXISTED="unknown"
ENV_BACKUP=""
PG_VOLUME_NAME=""
BACKUP_DIR=""
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
check_docker_compose() {
@@ -361,7 +374,77 @@ render_enterprise_config() {
# Execution steps
# ---------------------------------------------------------------------------
resolve_data_volume() {
combined_container_id() {
$DOCKER_COMPOSE_COMMAND ps -aq "$COMBINED_SERVICE" 2>/dev/null | head -1
}
container_data_mount() {
local container="$1"
[[ -n "$container" ]] || return 0
docker inspect "$container" --format \
'{{range .Mounts}}{{if eq .Destination "/var/lib/netbird"}}{{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' 2>/dev/null
}
# The name comes from the container, so `-v` cannot invent an empty volume here.
# 0 = empty, 1 = holds data, 2 = could not determine. A failed listing must not
# be reported as empty: that would abort a healthy migration over a pull error
# or an unreadable bind mount.
data_dir_state() {
local src="$1" out
if [[ "$src" == /* ]]; then
[[ -d "$src" ]] || return 2
out=$(ls -A "$src" 2>/dev/null) || return 2
else
docker volume inspect "$src" &> /dev/null || return 0
out=$(docker run --rm -v "${src}:/d:ro" busybox sh -c 'ls -A /d' 2>/dev/null) || return 2
fi
[[ -z "$out" ]] && return 0
return 1
}
check_data_directory() {
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
local container
container=$(combined_container_id)
if [[ -z "$container" ]]; then
echo "" > /dev/stderr
echo "No container found for service '$COMBINED_SERVICE'." > /dev/stderr
echo "The migration backs up the store by copying it out of that container," > /dev/stderr
echo "so it has to exist. Start the deployment and re-run:" > /dev/stderr
echo " $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
exit 1
fi
local src
src=$(container_data_mount "$container")
if [[ -z "$src" ]]; then
echo "" > /dev/stderr
echo "The '$COMBINED_SERVICE' container has nothing mounted at /var/lib/netbird." > /dev/stderr
echo "Cannot locate the NetBird store to back it up." > /dev/stderr
exit 1
fi
local state=0
data_dir_state "$src" || state=$?
if [[ $state -eq 0 ]]; then
echo "" > /dev/stderr
echo "The NetBird data directory is empty:" > /dev/stderr
echo " $src" > /dev/stderr
echo "There is nothing to migrate. Check that you are running this from the" > /dev/stderr
echo "deployment directory of the NetBird install you mean to migrate." > /dev/stderr
exit 1
fi
if [[ $state -eq 2 ]]; then
echo " ⚠ Could not read $src to confirm it holds data — continuing." > /dev/stderr
echo " The backup step still fails loudly if it turns out to be empty." > /dev/stderr
fi
echo " Data directory: $src"
}
# Only for the Postgres volume, which has no container to read it off yet.
resolve_compose_volume() {
local short="$1"
local actual
# Resolve project-prefixed volume name from Docker Compose config first.
@@ -391,18 +474,21 @@ resolve_data_volume() {
backup_sqlite() {
BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
local data_volume_actual
data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
echo "Backing up SQLite store from volume '$data_volume_actual' to $BACKUP_DIR ..."
docker run --rm \
-v "${data_volume_actual}:/var/lib/netbird:ro" \
-v "${BACKUP_DIR}:/backup" \
busybox \
sh -c 'cp -a /var/lib/netbird/. /backup/ 2>/dev/null || true'
local container
container=$(combined_container_id)
if [[ -z "$container" ]]; then
echo " ⚠ No container found for '$COMBINED_SERVICE' — cannot back up the store." > /dev/stderr
exit 1
fi
echo "Backing up the NetBird store to $BACKUP_DIR ..."
docker cp "${container}:/var/lib/netbird/." "$BACKUP_DIR/"
local copied
copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1)
if [[ -z "$copied" ]]; then
echo " ⚠ Backup directory is empty — the volume '$data_volume_actual' didn't contain data. Aborting." > /dev/stderr
echo " ⚠ Backup directory is empty — /var/lib/netbird held no data. Aborting." > /dev/stderr
exit 1
fi
echo " done"
@@ -414,6 +500,135 @@ run_migrate_store() {
echo " done"
}
# ---------------------------------------------------------------------------
# Rollback — a failed run must not leave the operator with a stopped stack and
# half-written artifacts.
# ---------------------------------------------------------------------------
# Resolve the name Compose would give the Postgres volume before the override
# exists, so a leftover volume can be spotted up front.
compose_project_name() {
local container project
container=$($DOCKER_COMPOSE_COMMAND ps -aq 2>/dev/null | head -1)
if [[ -n "$container" ]]; then
project=$(docker inspect "$container" \
--format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null)
if [[ -n "$project" ]]; then
echo "$project"
return 0
fi
fi
project=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval '.name // ""' - 2>/dev/null)
if [[ -n "$project" ]] && [[ "$project" != "null" ]]; then
echo "$project"
fi
return 0
}
postgres_volume_name() {
local project
project=$(compose_project_name)
if [[ -n "$project" ]]; then
echo "${project}_netbird_postgres"
fi
return 0
}
# Postgres skips initdb when its data directory is non-empty, so a volume left
# behind by an interrupted run would keep the old password and old contents,
# and migrate-store would fail against it.
check_stale_postgres_volume() {
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
PG_VOLUME_NAME=$(postgres_volume_name)
if [[ -z "$PG_VOLUME_NAME" ]]; then
echo ""
echo " ⚠ Could not determine the Compose project name, so a Postgres volume"
echo " left over from an earlier attempt cannot be checked for. If a"
echo " previous run failed, remove it before continuing:"
echo " docker volume ls | grep netbird_postgres"
return 0
fi
docker volume inspect "$PG_VOLUME_NAME" &> /dev/null || return 0
echo ""
echo " ⚠ A Postgres volume from an earlier attempt already exists:"
echo " $PG_VOLUME_NAME"
echo " Postgres does not re-initialise a non-empty data directory, so the"
echo " migration would run against stale credentials and stale data."
local remove
remove=$(read_yes_no " Remove it and continue?" "y")
if [[ "$remove" != "yes" ]]; then
echo "" > /dev/stderr
echo "Aborted. Remove it manually with: docker volume rm $PG_VOLUME_NAME" > /dev/stderr
exit 1
fi
docker volume rm "$PG_VOLUME_NAME" > /dev/null
echo " Removed."
}
# Undo whatever this run changed and start the previous deployment again.
rollback() {
ROLLBACK_STATE="done"
echo ""
echo "──────────────────────────────────────────────────────────────────────"
echo " Migration failed — restoring the previous deployment"
echo "──────────────────────────────────────────────────────────────────────"
# Resolve while the override is still present; without it Compose no longer
# knows about the Postgres volume.
local pg_volume="$PG_VOLUME_NAME"
if [[ -z "$pg_volume" ]] && [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
pg_volume=$(postgres_volume_name)
fi
echo ""
echo "Stopping services ..."
$DOCKER_COMPOSE_COMMAND down || true
echo "Removing generated files ..."
rm -f "$OVERRIDE_FILE" "$ENTERPRISE_CONFIG_FILE"
# Restore .env to exactly what it was, or remove it if this run created it.
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
mv -f "$ENV_BACKUP" .env || echo " ⚠ Could not restore .env from $ENV_BACKUP." > /dev/stderr
elif [[ "$ENV_EXISTED" == "no" ]]; then
rm -f .env || true
fi
# Only ever the volume this run created — never the NetBird data volume.
if [[ -n "$pg_volume" ]] && [[ "$pg_volume" != "null" ]]; then
echo "Removing Postgres volume $pg_volume ..."
docker volume rm "$pg_volume" &> /dev/null || true
fi
echo "Starting the previous deployment ..."
if ! $DOCKER_COMPOSE_COMMAND up -d; then
echo ""
echo " ⚠ Could not start the previous deployment automatically." > /dev/stderr
echo " Run: $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
fi
echo ""
echo "Rolled back. Your docker-compose.yml, config.yaml and the NetBird data"
echo "volume were never modified."
if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
echo "The SQLite backup taken during this run is kept at:"
echo " $BACKUP_DIR"
fi
echo "──────────────────────────────────────────────────────────────────────"
}
on_exit() {
local code=$?
trap - EXIT
if [[ $code -ne 0 ]] && [[ "$ROLLBACK_STATE" == "armed" ]]; then
rollback
fi
exit $code
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
@@ -541,9 +756,15 @@ init_migration() {
ENABLE_FLOW="no"
echo "Step 3 (traffic flow) skipped — requires Postgres."
fi
check_data_directory
check_stale_postgres_volume
}
apply_changes() {
# From here on a failure must roll the deployment back.
ROLLBACK_STATE="armed"
echo ""
echo "Writing $OVERRIDE_FILE ..."
install -m 644 /dev/null "$OVERRIDE_FILE"
@@ -564,6 +785,14 @@ apply_changes() {
# picks it up automatically.
echo "Writing .env additions (mode 600) ..."
local ENV_FILE=".env"
# Snapshot the operator's .env so a rollback can restore it byte for byte.
if [[ -f "$ENV_FILE" ]]; then
ENV_EXISTED="yes"
ENV_BACKUP="${ENV_FILE}.pre-enterprise-$(date +%Y%m%d-%H%M%S)"
cp -p "$ENV_FILE" "$ENV_BACKUP"
else
ENV_EXISTED="no"
fi
touch "$ENV_FILE"
chmod 600 "$ENV_FILE"
{
@@ -592,11 +821,16 @@ apply_changes() {
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
echo ""
echo "Stopping existing services (volumes preserved) ..."
$DOCKER_COMPOSE_COMMAND down
# Stop, but keep the containers: the backup reads the store out of one.
echo "Stopping services so the store is quiescent ..."
$DOCKER_COMPOSE_COMMAND stop
backup_sqlite
echo ""
echo "Removing stopped containers (volumes preserved) ..."
$DOCKER_COMPOSE_COMMAND down
echo ""
echo "Starting Postgres ..."
$DOCKER_COMPOSE_COMMAND up -d postgres
@@ -626,6 +860,9 @@ apply_changes() {
echo ""
echo "Migration complete."
# Nothing left to undo.
ROLLBACK_STATE="disarmed"
}
print_summary() {
@@ -643,6 +880,7 @@ print_summary() {
echo " $OVERRIDE_FILE"
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
echo " .env (license key + secrets, mode 600)"
[[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo " $ENV_BACKUP (.env as it was before this run)"
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)"
echo ""
echo " Tail logs:"
@@ -651,19 +889,27 @@ print_summary() {
echo "──────────────────────────────────────────────────────────────────────"
echo " To revert"
echo "──────────────────────────────────────────────────────────────────────"
echo " $DOCKER_COMPOSE_COMMAND down"
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
# Resolve project-prefixed volume names now (before override is removed).
local pg_volume data_volume_actual
pg_volume=$(resolve_data_volume "netbird_postgres")
data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
echo " # Remove the Postgres volume FIRST, before deleting the override file:"
echo " docker volume rm $pg_volume"
# Resolve the project-prefixed volume name now, before the override is gone.
local pg_volume
pg_volume=$(resolve_compose_volume "netbird_postgres")
echo " # Stop, but keep the containers so the store can be copied back in:"
echo " $DOCKER_COMPOSE_COMMAND stop"
echo " # Restore SQLite from the backup created during this run:"
echo " docker run --rm -v ${data_volume_actual}:/var/lib/netbird -v ${BACKUP_DIR}:/backup busybox sh -c 'cp -a /backup/. /var/lib/netbird/'"
echo " docker cp ${BACKUP_DIR}/. \$($DOCKER_COMPOSE_COMMAND ps -aq $COMBINED_SERVICE):/var/lib/netbird/"
echo " $DOCKER_COMPOSE_COMMAND down"
echo " docker volume rm $pg_volume"
else
echo " $DOCKER_COMPOSE_COMMAND down"
fi
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
echo " mv $ENV_BACKUP .env # restores .env as it was before this run"
elif [[ "$ENV_EXISTED" == "no" ]]; then
echo " rm -f .env # created by this run"
else
echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
fi
echo " $DOCKER_COMPOSE_COMMAND up -d"
echo "──────────────────────────────────────────────────────────────────────"
}
@@ -672,6 +918,10 @@ print_summary() {
# Run
# ---------------------------------------------------------------------------
trap on_exit EXIT
# Turn signals into a normal exit so the EXIT trap can roll back.
trap 'exit 130' INT TERM
init_migration
apply_changes
print_summary