Skip to content

Instantly share code, notes, and snippets.

@genox
Last active January 29, 2026 11:07
Show Gist options
  • Select an option

  • Save genox/03513724f78a05f70e8b2ca3981b0ba9 to your computer and use it in GitHub Desktop.

Select an option

Save genox/03513724f78a05f70e8b2ca3981b0ba9 to your computer and use it in GitHub Desktop.
#!/bin/bash
# =============================================================================
# Dokploy Node Setup Script for Debian 13 (Trixie) / Hetzner Cloud
# Interactive & Idempotent - safe to run multiple times
# =============================================================================
set -e
# -----------------------------------------------------------------------------
# Marker used to identify our config blocks (for idempotency)
# -----------------------------------------------------------------------------
MARKER="# DOKPLOY-NODE-SETUP"
# -----------------------------------------------------------------------------
# Colors
# -----------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
skip() { echo -e "${CYAN}[→]${NC} $1 (already configured)"; }
error() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
# -----------------------------------------------------------------------------
# Check root
# -----------------------------------------------------------------------------
[[ $EUID -ne 0 ]] && error "This script must be run as root"
# -----------------------------------------------------------------------------
# Interactive prompts
# -----------------------------------------------------------------------------
echo ""
echo "=============================================="
echo " Dokploy Node Setup - Debian 13 / Hetzner"
echo "=============================================="
echo ""
# Node type selection
echo -e "${BOLD}Node Type:${NC}"
echo " 1) Swarm worker - Ephemeral containers, large swap, tolerates memory spikes"
echo " 2) Static node - Databases/stateful services, smaller swap, stable memory"
echo ""
read -p "Select node type [1/2]: " NODE_TYPE_INPUT
case "$NODE_TYPE_INPUT" in
1|swarm|Swarm|SWARM|"")
NODE_TYPE="swarm"
SWAP_DEFAULT="8G"
SWAPPINESS=30
;;
2|static|Static|STATIC)
NODE_TYPE="static"
SWAP_DEFAULT="2G"
SWAPPINESS=10
;;
*)
error "Invalid selection. Use 1 for swarm or 2 for static."
;;
esac
echo ""
# Hostname
CURRENT_HOSTNAME=$(hostname)
read -p "Hostname [$CURRENT_HOSTNAME]: " HOSTNAME
HOSTNAME=${HOSTNAME:-$CURRENT_HOSTNAME}
# Timezone
CURRENT_TZ=$(timedatectl show -p Timezone --value 2>/dev/null || echo "UTC")
read -p "Timezone [$CURRENT_TZ]: " TIMEZONE
TIMEZONE=${TIMEZONE:-$CURRENT_TZ}
# Swap
CURRENT_SWAP=$(swapon --show --noheadings 2>/dev/null | awk '{print $3}' | head -1)
if [[ -n "$CURRENT_SWAP" ]]; then
echo "Swap already configured: $CURRENT_SWAP"
SWAP_DEFAULT=""
fi
read -p "Swap size (empty to skip) [$SWAP_DEFAULT]: " SWAP_SIZE
SWAP_SIZE=${SWAP_SIZE:-$SWAP_DEFAULT}
# SSH Port
CURRENT_SSH_PORT=$(grep -E "^Port " /etc/ssh/sshd_config 2>/dev/null | awk '{print $2}' || echo "22")
SSH_DEFAULT="22345"
read -p "SSH port [$SSH_DEFAULT]: " SSH_PORT
SSH_PORT=${SSH_PORT:-$SSH_DEFAULT}
# Confirm
echo ""
echo "Configuration:"
echo " Node type: $NODE_TYPE"
echo " Hostname: $HOSTNAME"
echo " Timezone: $TIMEZONE"
echo " Swap: ${SWAP_SIZE:-'skip'}"
echo " Swappiness: $SWAPPINESS (${NODE_TYPE} mode)"
echo " SSH Port: $SSH_PORT"
echo ""
if [[ "$SSH_PORT" != "22" ]]; then
echo -e "${YELLOW}⚠ WARNING: Changing SSH port to $SSH_PORT${NC}"
echo " → Update Hetzner Cloud Firewall BEFORE running this script!"
echo " → Add inbound rule: TCP $SSH_PORT from 0.0.0.0/0"
echo " → Dokploy: use port $SSH_PORT when adding this server"
echo ""
fi
read -p "Proceed? [Y/n]: " CONFIRM
CONFIRM=${CONFIRM:-Y}
[[ ! "$CONFIRM" =~ ^[Yy]$ ]] && echo "Aborted." && exit 0
echo ""
# =============================================================================
# FUNCTIONS (all idempotent)
# =============================================================================
configure_hostname() {
if [[ "$(hostname)" == "$HOSTNAME" ]]; then
skip "Hostname"
else
log "Setting hostname to ${HOSTNAME}"
hostnamectl set-hostname "$HOSTNAME"
fi
# /etc/hosts entry (idempotent)
local SHORT_HOSTNAME="${HOSTNAME%%.*}"
if ! grep -q "$HOSTNAME" /etc/hosts 2>/dev/null; then
echo "127.0.1.1 ${HOSTNAME} ${SHORT_HOSTNAME}" >> /etc/hosts
log "Added hostname to /etc/hosts"
fi
}
configure_timezone() {
if [[ "$(timedatectl show -p Timezone --value)" == "$TIMEZONE" ]]; then
skip "Timezone"
else
log "Setting timezone to ${TIMEZONE}"
timedatectl set-timezone "$TIMEZONE"
fi
# Ensure time sync
systemctl enable --now systemd-timesyncd 2>/dev/null || true
}
configure_auto_updates() {
if dpkg -l | grep -q "^ii.*unattended-upgrades"; then
skip "Unattended-upgrades package"
else
log "Installing unattended-upgrades..."
apt update -qq
apt install -y unattended-upgrades apt-listchanges
fi
local CONF="/etc/apt/apt.conf.d/20auto-upgrades"
if [[ -f "$CONF" ]] && grep -q "$MARKER" "$CONF" 2>/dev/null; then
skip "Auto-upgrades config"
else
log "Configuring auto-upgrades..."
cat > "$CONF" << EOF
$MARKER
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
EOF
fi
local CONF2="/etc/apt/apt.conf.d/50unattended-upgrades"
if [[ -f "$CONF2" ]] && grep -q "$MARKER" "$CONF2" 2>/dev/null; then
skip "Unattended-upgrades config"
else
log "Configuring unattended-upgrades..."
cat > "$CONF2" << EOF
$MARKER
Unattended-Upgrade::Allowed-Origins {
"\${distro_id}:\${distro_codename}";
"\${distro_id}:\${distro_codename}-security";
"\${distro_id}:\${distro_codename}-updates";
};
Unattended-Upgrade::Package-Blacklist {};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF
fi
systemctl enable --now unattended-upgrades 2>/dev/null || true
}
configure_needrestart() {
if dpkg -l | grep -q "^ii.*needrestart"; then
skip "Needrestart package"
else
log "Installing needrestart..."
apt install -y needrestart
fi
local CONF="/etc/needrestart/needrestart.conf"
if [[ -f "$CONF" ]] && grep -q '^\$nrconf{restart} = '"'"'a'"'"';' "$CONF" 2>/dev/null; then
skip "Needrestart auto-mode"
else
log "Setting needrestart to automatic mode..."
sed -i "s/#\$nrconf{restart} = 'i';/\$nrconf{restart} = 'a';/" "$CONF" 2>/dev/null || true
fi
}
configure_journald() {
local CONF="/etc/systemd/journald.conf.d/size-limit.conf"
if [[ -f "$CONF" ]] && grep -q "$MARKER" "$CONF" 2>/dev/null; then
skip "Journald limits"
else
log "Configuring journald limits (200MB max)..."
mkdir -p /etc/systemd/journald.conf.d
cat > "$CONF" << EOF
$MARKER
[Journal]
SystemMaxUse=200M
SystemKeepFree=100M
SystemMaxFileSize=50M
RuntimeMaxUse=50M
RuntimeKeepFree=50M
Compress=yes
MaxRetentionSec=2week
EOF
systemctl restart systemd-journald
fi
}
configure_docker_logging() {
local CONF="/etc/docker/daemon.json"
if [[ -f "$CONF" ]] && grep -q '"max-size"' "$CONF" 2>/dev/null; then
skip "Docker log rotation"
else
log "Configuring Docker log rotation (10MB x 3 per container)..."
mkdir -p /etc/docker
# If file exists but no log config, we need to merge
if [[ -f "$CONF" ]] && [[ -s "$CONF" ]]; then
# Backup and merge using jq if available
if command -v jq &>/dev/null; then
cp "$CONF" "${CONF}.bak"
jq '. + {"log-driver": "json-file", "log-opts": {"max-size": "10m", "max-file": "3", "compress": "true"}}' "$CONF" > "${CONF}.tmp"
mv "${CONF}.tmp" "$CONF"
else
warn "jq not found, cannot merge existing daemon.json - skipping"
return
fi
else
cat > "$CONF" << 'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3",
"compress": "true"
}
}
EOF
fi
# Restart Docker if running
if systemctl is-active --quiet docker 2>/dev/null; then
systemctl restart docker
log "Docker restarted with new log settings"
fi
fi
}
configure_logrotate() {
local CONF="/etc/logrotate.d/small-vps"
if [[ -f "$CONF" ]] && grep -q "$MARKER" "$CONF" 2>/dev/null; then
skip "Logrotate config"
else
log "Configuring aggressive logrotate..."
cat > "$CONF" << EOF
$MARKER
/var/log/syslog
/var/log/messages
/var/log/auth.log
/var/log/kern.log
{
rotate 4
weekly
maxsize 50M
missingok
notifempty
compress
delaycompress
sharedscripts
postrotate
/usr/lib/rsyslog/rsyslog-rotate 2>/dev/null || true
endscript
}
/var/log/apt/history.log
/var/log/apt/term.log
{
rotate 4
monthly
maxsize 10M
missingok
notifempty
compress
}
EOF
fi
}
configure_apt_cleanup() {
local CONF="/etc/apt/apt.conf.d/99-auto-clean"
if [[ -f "$CONF" ]] && grep -q "$MARKER" "$CONF" 2>/dev/null; then
skip "APT auto-clean"
else
log "Configuring APT auto-clean..."
cat > "$CONF" << EOF
$MARKER
APT::Keep-Downloaded-Packages "false";
DPkg::Post-Invoke { "rm -f /var/cache/apt/archives/*.deb || true"; };
EOF
fi
# Clean existing cache (always safe to run)
apt clean 2>/dev/null || true
apt autoremove -y 2>/dev/null || true
}
configure_swap() {
[[ -z "$SWAP_SIZE" ]] && return
if [[ -f /swapfile ]]; then
skip "Swap file"
else
log "Creating ${SWAP_SIZE} swap file..."
fallocate -l "$SWAP_SIZE" /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
# Add to fstab if not present
if ! grep -q '/swapfile' /etc/fstab 2>/dev/null; then
echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
fi
# Swappiness settings (idempotent) - varies by node type
local CONF="/etc/sysctl.d/99-swap.conf"
if [[ -f "$CONF" ]] && grep -q "$MARKER" "$CONF" 2>/dev/null; then
# Check if swappiness needs updating
local CURRENT_SWAPPINESS=$(grep "vm.swappiness" "$CONF" 2>/dev/null | cut -d= -f2)
if [[ "$CURRENT_SWAPPINESS" != "$SWAPPINESS" ]]; then
log "Updating swappiness to $SWAPPINESS ($NODE_TYPE mode)..."
cat > "$CONF" << EOF
$MARKER
# Node type: $NODE_TYPE
vm.swappiness=$SWAPPINESS
vm.vfs_cache_pressure=50
EOF
sysctl -p "$CONF" 2>/dev/null || true
else
skip "Swap sysctl"
fi
else
log "Configuring swap sysctl (swappiness=$SWAPPINESS for $NODE_TYPE)..."
cat > "$CONF" << EOF
$MARKER
# Node type: $NODE_TYPE
vm.swappiness=$SWAPPINESS
vm.vfs_cache_pressure=50
EOF
sysctl -p "$CONF" 2>/dev/null || true
fi
}
configure_sysctl() {
local CONF="/etc/sysctl.d/99-small-vps.conf"
if [[ -f "$CONF" ]] && grep -q "$MARKER" "$CONF" 2>/dev/null; then
skip "Sysctl tuning"
else
log "Applying sysctl optimizations..."
cat > "$CONF" << EOF
$MARKER
# Connection handling
net.core.somaxconn = 1024
net.ipv4.tcp_max_syn_backlog = 1024
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_probes = 5
net.ipv4.tcp_keepalive_intvl = 15
# Security hardening
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
# File limits
fs.file-max = 65535
fs.inotify.max_user_watches = 65536
EOF
sysctl -p "$CONF" 2>/dev/null || true
fi
}
configure_ssh_port() {
local SSHD_CONF="/etc/ssh/sshd_config"
local CURRENT_PORT=$(grep -E "^Port " "$SSHD_CONF" 2>/dev/null | awk '{print $2}' || echo "22")
if [[ "$CURRENT_PORT" == "$SSH_PORT" ]]; then
skip "SSH port ($SSH_PORT)"
return
fi
log "Changing SSH port to $SSH_PORT..."
# Backup original config
cp "$SSHD_CONF" "${SSHD_CONF}.bak"
# Update or add Port directive
if grep -qE "^#?Port " "$SSHD_CONF"; then
sed -i "s/^#\?Port .*/Port $SSH_PORT/" "$SSHD_CONF"
else
echo "Port $SSH_PORT" >> "$SSHD_CONF"
fi
# Validate config before restarting
if sshd -t 2>/dev/null; then
systemctl restart sshd
log "SSH now listening on port $SSH_PORT"
warn "Reconnect with: ssh -p $SSH_PORT root@$HOSTNAME"
else
error "Invalid sshd config! Restoring backup..."
cp "${SSHD_CONF}.bak" "$SSHD_CONF"
error "SSH port change failed - config restored"
fi
}
install_tools() {
local TOOLS="htop ncdu curl wget vim jq tree dnsutils"
local MISSING=""
for tool in $TOOLS; do
if ! command -v "$tool" &>/dev/null; then
MISSING="$MISSING $tool"
fi
done
if [[ -z "$MISSING" ]]; then
skip "Utility tools"
else
log "Installing utilities:$MISSING"
apt update -qq
apt install -y $MISSING
fi
}
configure_cleanup_cron() {
local CONF="/etc/cron.weekly/docker-cleanup"
if [[ -f "$CONF" ]] && grep -q "$MARKER" "$CONF" 2>/dev/null; then
skip "Weekly cleanup cron"
else
log "Setting up weekly cleanup cron..."
cat > "$CONF" << EOF
#!/bin/bash
$MARKER
# Weekly cleanup for small VPS
# Docker cleanup (images/containers older than 7 days)
docker system prune -af --volumes --filter "until=168h" 2>/dev/null || true
# Journal vacuum
journalctl --vacuum-time=14d 2>/dev/null || true
# APT cleanup
apt-get clean 2>/dev/null || true
apt-get autoremove -y 2>/dev/null || true
EOF
chmod +x "$CONF"
fi
}
# =============================================================================
# RUN ALL CONFIGURATIONS
# =============================================================================
configure_hostname
configure_timezone
configure_auto_updates
configure_needrestart
configure_journald
configure_docker_logging
configure_logrotate
configure_apt_cleanup
configure_swap
configure_sysctl
install_tools
configure_cleanup_cron
configure_ssh_port
# =============================================================================
# SUMMARY
# =============================================================================
echo ""
echo "=============================================="
echo " Setup Complete!"
echo "=============================================="
echo ""
echo " Node type: $NODE_TYPE"
echo " Hostname: $(hostname)"
echo " Timezone: $(timedatectl show -p Timezone --value)"
echo " Swap: $(swapon --show --noheadings 2>/dev/null | awk '{print $3}' || echo 'none')"
echo " Swappiness: $(cat /proc/sys/vm/swappiness)"
echo " SSH port: $SSH_PORT"
echo ""
echo " Configured:"
echo " • Automatic security updates"
echo " • Journald limited to 200MB"
echo " • Docker logs: 10MB × 3 per container"
echo " • Aggressive logrotate"
echo " • APT auto-clean"
echo " • Weekly Docker prune cron"
echo " • Sysctl network/security tuning"
echo ""
if [[ "$NODE_TYPE" == "swarm" ]]; then
echo " Swarm node optimizations:"
echo " • Large swap (${SWAP_SIZE:-default}) for memory spikes"
echo " • Higher swappiness ($SWAPPINESS) - allows swap during imports"
echo ""
fi
if [[ "$NODE_TYPE" == "static" ]]; then
echo " Static node optimizations:"
echo " • Smaller swap for stable workloads"
echo " • Lower swappiness ($SWAPPINESS) - prefers RAM"
echo ""
fi
echo " Next steps:"
echo " 1. Ensure Hetzner Cloud Firewall allows port $SSH_PORT"
echo " 2. Add server to Dokploy UI → Servers → Add Server (port $SSH_PORT)"
echo " 3. Run 'Setup Server' in Dokploy to install Docker/Traefik"
echo ""
echo " This script is idempotent - safe to run again."
echo ""
@genox
Copy link
Author

genox commented Jan 28, 2026

curl -sSL https://gist.githubusercontent.com/genox/03513724f78a05f70e8b2ca3981b0ba9/raw/dokploy-node-setup.sh -o setup.sh
chmod +x setup.sh
./setup.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment