Skip to content

Instantly share code, notes, and snippets.

@mortn
Forked from LeMoonStar/netbird-quadlets-setup.sh
Created February 20, 2026 22:32
Show Gist options
  • Select an option

  • Save mortn/d8fb3ee00218d82e500c39b6c02098dc to your computer and use it in GitHub Desktop.

Select an option

Save mortn/d8fb3ee00218d82e500c39b6c02098dc to your computer and use it in GitHub Desktop.
Netbird Quadlet Setup
#!/bin/bash
set -e
# NetBird Setup for Podman Quadlets
# Target: /etc/containers/systemd
# Based on https://github.com/netbirdio/netbird/blob/614e7d5b90667b807e788dd3f0d7421dac4a8cac/infrastructure_files/getting-started.sh
# Mainly translated using GEMINI 3 PRO (After weeks of trying to get Netbird to work, this Finally worked...)
# ORIGINAL LICENSE:
# BSD 3-Clause License
#
# Copyright (c) 2022 NetBird GmbH & AUTHORS
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# Sed pattern to strip base64 padding characters
SED_STRIP_PADDING='s/=//g'
# Global Variables
SYSTEMD_DIR="/etc/containers/systemd"
CONFIG_DIR="/opt/netbird/config"
DATA_DIR="/opt/netbird/data"
ENABLE_CADDY=true
ENABLE_AUTOUPDATE=false
PROXY_NETWORK_NAME=""
EXISTING_INSTALL=false
OVERWRITE_CONFIG=false
# --- Helper Functions ---
check_root() {
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (sudo). Quadlets in /etc/containers/systemd require root privileges."
exit 1
fi
}
check_podman() {
if ! command -v podman &> /dev/null; then
echo "Podman is not installed." > /dev/stderr
exit 1
fi
}
check_jq() {
if ! command -v jq &> /dev/null; then
echo "jq is not installed. Please install it (e.g., apt install jq)." > /dev/stderr
exit 1
fi
}
stop_services() {
echo "Checking for running services..."
# Attempt to stop services if they exist to allow clean re-setup
set +e
if systemctl list-units --full -all | grep -q "netbird-management.service"; then
echo "Stopping existing NetBird services..."
systemctl stop netbird-caddy netbird-dashboard netbird-signal netbird-relay netbird-management netbird-coturn 2>/dev/null
fi
set -e
}
get_main_ip_address() {
interface=$(ip route | grep default | awk '{print $5}' | head -n 1)
ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1)
echo "$ip_address"
}
check_nb_domain() {
DOMAIN=$1
if [[ "$DOMAIN-x" == "-x" ]]; then
echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr
return 1
fi
if [[ "$DOMAIN" == "netbird.example.com" ]]; then
echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr
return 1
fi
return 0
}
read_nb_domain() {
READ_NETBIRD_DOMAIN=""
echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/tty
read -r READ_NETBIRD_DOMAIN < /dev/tty
if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then
read_nb_domain
fi
echo "$READ_NETBIRD_DOMAIN"
}
get_turn_external_ip() {
TURN_EXTERNAL_IP_CONFIG="#external-ip="
IP=$(curl -s -4 https://jsonip.com | jq -r '.ip')
if [[ "x-$IP" != "x-" ]]; then
TURN_EXTERNAL_IP_CONFIG="external-ip=$IP"
fi
echo "$TURN_EXTERNAL_IP_CONFIG"
}
# --- Interaction Prompts ---
ask_directories() {
echo -e "\n--- Directory Setup ---"
read -p "Config Directory [Default: $CONFIG_DIR]: " input_config
CONFIG_DIR=${input_config:-$CONFIG_DIR}
read -p "Data Directory [Default: $DATA_DIR]: " input_data
DATA_DIR=${input_data:-$DATA_DIR}
echo "Using Config: $CONFIG_DIR"
echo "Using Data: $DATA_DIR"
}
ask_caddy_and_network() {
echo -e "\n--- Proxy Setup ---"
echo "Do you want to deploy the embedded Caddy reverse proxy?"
read -p "Deploy Caddy? [Y/n]: " input_caddy
if [[ "$input_caddy" =~ ^[Nn]$ ]]; then
ENABLE_CADDY=false
echo -e "\n--- Network Setup ---"
echo "Caddy is disabled. How will your external proxy access these containers?"
echo "Option A: Add these containers to your proxy's network."
echo "Option B: Add your proxy container to the 'netbird' network manually."
read -p "Do you want these containers to join an existing external network? [y/N]: " input_join
if [[ "$input_join" =~ ^[Yy]$ ]]; then
echo "Please enter the NAME of the Podman network (e.g. 'npm_default')."
read -p "External Network Name: " input_net
if [[ -z "$input_net" ]]; then
echo "Error: Network name cannot be empty."
exit 1
fi
PROXY_NETWORK_NAME="$input_net"
echo "Containers will attach to: netbird.network AND $PROXY_NETWORK_NAME"
else
echo "No external network selected. Ensure your proxy is connected to the 'netbird' network."
fi
else
ENABLE_CADDY=true
echo "Caddy enabled."
fi
}
ask_autoupdate() {
echo -e "\n--- Maintenance ---"
read -p "Enable Podman Auto Update for these containers? [y/N]: " input_auto
if [[ "$input_auto" =~ ^[Yy]$ ]]; then
ENABLE_AUTOUPDATE=true
echo "Auto-update enabled (registry)."
else
ENABLE_AUTOUPDATE=false
fi
}
ask_overwrite() {
echo -e "\n--- Existing Config Found ---"
echo "Configuration files detected in $CONFIG_DIR."
echo "Do you want to OVERWRITE them? (This will reset keys and secrets!)"
read -p "Overwrite config? [y/N]: " input_overwrite
if [[ "$input_overwrite" =~ ^[Yy]$ ]]; then
OVERWRITE_CONFIG=true
EXISTING_INSTALL=false
echo "WARNING: Config will be overwritten. Backups will be created."
else
OVERWRITE_CONFIG=false
EXISTING_INSTALL=true
echo "Preserving existing configuration."
fi
}
# --- Main Logic ---
init_environment() {
check_root
check_podman
check_jq
# 1. Stop services first to release locks
stop_services
# 2. Ask user for preferences
ask_directories
# Check for existing config immediately after knowing the directory
if [[ -f "$CONFIG_DIR/management.json" ]]; then
ask_overwrite
fi
ask_caddy_and_network
ask_autoupdate
# Create storage directories
mkdir -p "$CONFIG_DIR"
mkdir -p "$DATA_DIR/caddy"
mkdir -p "$DATA_DIR/management"
# 3. Handle Configuration Logic
if [ "$EXISTING_INSTALL" = true ]; then
# PRESERVE MODE
echo -e "\n[INFO] Loading existing configuration..."
# Extract domain from existing config
EXISTING_URI=$(jq -r '.Signal.URI' "$CONFIG_DIR/management.json")
NETBIRD_DOMAIN=$(echo "$EXISTING_URI" | cut -d':' -f1)
echo " Detected Domain: $NETBIRD_DOMAIN"
NETBIRD_PORT=443
if [[ "$EXISTING_URI" != *":"* ]] || [[ "$EXISTING_URI" == *":80" ]]; then
NETBIRD_PORT=80
fi
CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT"
else
# NEW / OVERWRITE MODE
if [ "$OVERWRITE_CONFIG" = true ]; then
echo "Backing up old config..."
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mv "$CONFIG_DIR/management.json" "$CONFIG_DIR/management.json.bak.$TIMESTAMP" 2>/dev/null || true
mv "$CONFIG_DIR/dashboard.env" "$CONFIG_DIR/dashboard.env.bak.$TIMESTAMP" 2>/dev/null || true
mv "$CONFIG_DIR/relay.env" "$CONFIG_DIR/relay.env.bak.$TIMESTAMP" 2>/dev/null || true
mv "$CONFIG_DIR/turnserver.conf" "$CONFIG_DIR/turnserver.conf.bak.$TIMESTAMP" 2>/dev/null || true
mv "$CONFIG_DIR/Caddyfile" "$CONFIG_DIR/Caddyfile.bak.$TIMESTAMP" 2>/dev/null || true
fi
CADDY_SECURE_DOMAIN=""
NETBIRD_PORT=80
NETBIRD_HTTP_PROTOCOL="http"
NETBIRD_RELAY_PROTO="rel"
TURN_USER="self"
TURN_PASSWORD=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32)
TURN_MIN_PORT=49152
TURN_MAX_PORT=65535
TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip)
echo -e "\n--- Domain Setup ---"
if ! check_nb_domain "$NETBIRD_DOMAIN"; then
NETBIRD_DOMAIN=$(read_nb_domain)
fi
if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then
NETBIRD_DOMAIN=$(get_main_ip_address)
else
NETBIRD_PORT=443
CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT"
NETBIRD_HTTP_PROTOCOL="https"
NETBIRD_RELAY_PROTO="rels"
fi
fi
# 5. Render Configuration Files (Only if NOT existing install/preserve mode)
if [ "$EXISTING_INSTALL" = false ]; then
echo "Rendering configuration files to $CONFIG_DIR..."
if [ "$ENABLE_CADDY" = true ]; then
render_caddyfile > "$CONFIG_DIR/Caddyfile"
fi
render_dashboard_env > "$CONFIG_DIR/dashboard.env"
render_management_json > "$CONFIG_DIR/management.json"
render_turn_server_conf > "$CONFIG_DIR/turnserver.conf"
render_relay_env > "$CONFIG_DIR/relay.env"
else
# If switching from No-Caddy TO Caddy on an existing install, we might need a Caddyfile
if [ "$ENABLE_CADDY" = true ] && [ ! -f "$CONFIG_DIR/Caddyfile" ]; then
echo "Warning: Caddy enabled but no Caddyfile found. Generating one..."
render_caddyfile > "$CONFIG_DIR/Caddyfile"
fi
fi
# 6. Render Quadlet Files (Always overwrite these to apply updates/changes)
echo "Rendering/Updating Quadlet files in $SYSTEMD_DIR..."
render_network_quadlet > "$SYSTEMD_DIR/netbird.network"
if [ "$ENABLE_CADDY" = true ]; then
render_caddy_quadlet > "$SYSTEMD_DIR/netbird-caddy.container"
else
rm -f "$SYSTEMD_DIR/netbird-caddy.container"
fi
render_dashboard_quadlet > "$SYSTEMD_DIR/netbird-dashboard.container"
render_signal_quadlet > "$SYSTEMD_DIR/netbird-signal.container"
render_relay_quadlet > "$SYSTEMD_DIR/netbird-relay.container"
render_management_quadlet > "$SYSTEMD_DIR/netbird-management.container"
render_coturn_quadlet > "$SYSTEMD_DIR/netbird-coturn.container"
echo "Reloading systemd..."
systemctl daemon-reload
SERVICES="netbird-dashboard netbird-signal netbird-relay netbird-management netbird-coturn"
if [ "$ENABLE_CADDY" = true ]; then
SERVICES="$SERVICES netbird-caddy"
fi
echo "Starting services..."
systemctl start $SERVICES
echo -e "\nDone!\n"
echo "Configuration files: $CONFIG_DIR"
echo "Data directory: $DATA_DIR"
if [ "$ENABLE_CADDY" = false ]; then
echo "--- PROXY CONFIGURATION ---"
if [ -n "$PROXY_NETWORK_NAME" ]; then
echo "Containers are on network: $PROXY_NETWORK_NAME"
else
echo "Containers are ONLY on network: netbird"
echo "You must attach your proxy container to this network manually."
fi
echo "Target Hostnames: netbird-dashboard, netbird-signal, netbird-management, netbird-relay"
fi
}
# --- Configuration Renderers ---
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${CADDY_SECURE_DOMAIN} {
import security_headers
reverse_proxy /relay* relay:80
reverse_proxy /ws-proxy/signal* signal:80
reverse_proxy /signalexchange.SignalExchange/* h2c://signal:10000
reverse_proxy /api/* management:80
reverse_proxy /ws-proxy/management* management:80
reverse_proxy /management.ManagementService/* h2c://management:80
reverse_proxy /oauth2/* management:80
reverse_proxy /* dashboard:80
}
EOF
}
render_turn_server_conf() {
cat <<EOF
listening-port=3478
$TURN_EXTERNAL_IP_CONFIG
tls-listening-port=5349
min-port=$TURN_MIN_PORT
max-port=$TURN_MAX_PORT
fingerprint
lt-cred-mech
user=$TURN_USER:$TURN_PASSWORD
realm=wiretrustee.com
cert=/etc/coturn/certs/cert.pem
pkey=/etc/coturn/private/privkey.pem
log-file=stdout
no-software-attribute
pidfile="/var/tmp/turnserver.pid"
no-cli
EOF
}
render_management_json() {
cat <<EOF
{
"Stuns": [
{
"Proto": "udp",
"URI": "stun:$NETBIRD_DOMAIN:3478"
}
],
"Relay": {
"Addresses": ["$NETBIRD_RELAY_PROTO://$NETBIRD_DOMAIN:$NETBIRD_PORT"],
"CredentialsTTL": "24h",
"Secret": "$NETBIRD_RELAY_AUTH_SECRET"
},
"Signal": {
"Proto": "$NETBIRD_HTTP_PROTOCOL",
"URI": "$NETBIRD_DOMAIN:$NETBIRD_PORT"
},
"Datadir": "/var/lib/netbird",
"DataStoreEncryptionKey": "$DATASTORE_ENCRYPTION_KEY",
"EmbeddedIdP": {
"Enabled": true,
"Issuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2",
"DashboardRedirectURIs": [
"$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth",
"$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth"
]
}
}
EOF
}
render_dashboard_env() {
cat <<EOF
NETBIRD_MGMT_API_ENDPOINT=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
NETBIRD_MGMT_GRPC_API_ENDPOINT=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
AUTH_AUDIENCE=netbird-dashboard
AUTH_CLIENT_ID=netbird-dashboard
AUTH_CLIENT_SECRET=
AUTH_AUTHORITY=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2
USE_AUTH0=false
AUTH_SUPPORTED_SCOPES=openid profile email offline_access
AUTH_REDIRECT_URI=/nb-auth
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
NGINX_SSL_PORT=443
LETSENCRYPT_DOMAIN=none
EOF
}
render_relay_env() {
cat <<EOF
NB_LOG_LEVEL=info
NB_LISTEN_ADDRESS=:80
NB_EXPOSED_ADDRESS=$NETBIRD_RELAY_PROTO://$NETBIRD_DOMAIN:$NETBIRD_PORT
NB_AUTH_SECRET=$NETBIRD_RELAY_AUTH_SECRET
EOF
}
# --- Quadlet Renderers ---
get_auto_update_line() {
if [ "$ENABLE_AUTOUPDATE" = true ]; then
echo "AutoUpdate=registry"
fi
}
get_extra_network_line() {
if [ -n "$PROXY_NETWORK_NAME" ]; then
echo "Network=$PROXY_NETWORK_NAME"
fi
}
render_network_quadlet() {
cat <<EOF
[Unit]
Description=NetBird Network
[Network]
NetworkName=netbird
EOF
}
render_caddy_quadlet() {
cat <<EOF
[Unit]
Description=NetBird Caddy Reverse Proxy
After=network-online.target netbird-network.service
[Container]
Image=docker.io/caddy:latest
ContainerName=netbird-caddy
Network=netbird.network
$(get_auto_update_line)
PublishPort=80:80
PublishPort=443:443
PublishPort=443:443/udp
Volume=$DATA_DIR/caddy:/data
Volume=$CONFIG_DIR/Caddyfile:/etc/caddy/Caddyfile:Z
[Service]
Restart=always
EOF
}
render_dashboard_quadlet() {
cat <<EOF
[Unit]
Description=NetBird Dashboard
After=network-online.target netbird-network.service
[Container]
Image=docker.io/netbirdio/dashboard:latest
ContainerName=netbird-dashboard
Network=netbird.network
$(get_extra_network_line)
NetworkAlias=dashboard
$(get_auto_update_line)
EnvironmentFile=$CONFIG_DIR/dashboard.env
[Service]
Restart=always
EOF
}
render_signal_quadlet() {
cat <<EOF
[Unit]
Description=NetBird Signal Service
After=network-online.target netbird-network.service
[Container]
Image=docker.io/netbirdio/signal:latest
ContainerName=netbird-signal
Network=netbird.network
$(get_extra_network_line)
NetworkAlias=signal
$(get_auto_update_line)
[Service]
Restart=always
EOF
}
render_relay_quadlet() {
cat <<EOF
[Unit]
Description=NetBird Relay Service
After=network-online.target netbird-network.service
[Container]
Image=docker.io/netbirdio/relay:latest
ContainerName=netbird-relay
Network=netbird.network
$(get_extra_network_line)
NetworkAlias=relay
$(get_auto_update_line)
EnvironmentFile=$CONFIG_DIR/relay.env
[Service]
Restart=always
EOF
}
render_management_quadlet() {
cat <<EOF
[Unit]
Description=NetBird Management Service
After=network-online.target netbird-network.service
[Container]
Image=docker.io/netbirdio/management:latest
ContainerName=netbird-management
Network=netbird.network
$(get_extra_network_line)
NetworkAlias=management
$(get_auto_update_line)
Volume=$DATA_DIR/management:/var/lib/netbird
Volume=$CONFIG_DIR/management.json:/etc/netbird/management.json:Z
Exec=--port 80 --log-file console --log-level info --disable-anonymous-metrics=false --single-account-mode-domain=netbird.selfhosted --dns-domain=netbird.selfhosted --idp-sign-key-refresh-enabled
[Service]
Restart=always
EOF
}
render_coturn_quadlet() {
cat <<EOF
[Unit]
Description=NetBird Coturn (TURN) Server
After=network-online.target
[Container]
Image=docker.io/coturn/coturn
ContainerName=netbird-coturn
# Coturn needs host networking to work correctly for STUN/TURN
Network=host
$(get_auto_update_line)
Volume=$CONFIG_DIR/turnserver.conf:/etc/turnserver.conf:ro,Z
Exec=-c /etc/turnserver.conf
[Service]
Restart=always
EOF
}
init_environment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment