Last active
January 29, 2026 11:07
-
-
Save genox/03513724f78a05f70e8b2ca3981b0ba9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 "" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.