Proxmox VE 9.1 - Configuration

Proxmox VE 9.1 - Configuration
Proxmox VE 9.1

As previously stated, I have been reading a lot of Brandon Lee and realized that I have been configuring my LXC's and Proxmox server with obsolete iptables.

This has resulted in a complete rewrite of the associated configuration scripts


Brandon Lee is located here

Complete Guide to Proxmox Containers in 2025: Docker VMs, LXC, and New OCI Support
Learn how to run Proxmox containers in 2025 using Docker VMs, LXC, and new OCI support with tips for performance, updates, and home lab.
Proxmox VE 9.1 Launches with OCI Image Support, vTPM Snapshots, and Big SDN Upgrades
See what is new in Proxmox VE 9.1, from OCI containers to vTPM and networking upgrades, and follow easy upgrade steps to get your cluster ready.
12 Proxmox Host Tweaks Worth Doing This Weekend
These Proxmox host tweaks focus on real-world performance, reliability, and cleanup tasks you can knock out in a weekend home lab maintenance window.
Proxmox Defaults I Leave Alone (And the Ones I Always Change)
Review Proxmox default settings for home labs, including VM hardware, ZFS tuning, backup retention, and which defaults to keep or change for stability.

Build Recommendations

Brandon Lee yet again but very good place to start

My Top 3 Proxmox Server Builds for Performance and Efficiency
Take a look at my top Proxmox server builds featuring Minisforum boards for performance and efficiency in home lab setups.

Background

The following changes were made

  • Previously rebuilt the server to a clean install of Proxmox VE 9.1
  • Rebuild all the LXC Debian 13 containers based on Proxmox Community Scripts
  • Review networking especially the sysctl commands
  • Implemented a lot of Brandon Lees code and ideas - he is very good.
  • Move all firewalls to nftables and ditch iptables - you can't on the Proxmox server
  • Test, test and test.

At time of writing and multiple reboots I have not found a problem with the server, the reverse proxy LXC or the Podman application servers since I have ditched Docker due to the November fiasco that created havoc for most enthusiasts.


Network Configuration

The following basically gives you an idea of the network

  • All IPs are issued by the router - ipv4 and ipv6 - some are fixed
  • The Proxmox server hosts all applications whether they are local or WAN enabled
  • The Proxmox server runs LXC's due to dated hardware, has GPU enabled via LXC pass through which is very different from VM pass through
  • I run a dedicated proxy on one LXC
  • I then run one primary application LXC and a couple of developmental types

Proxmox Server Code

Find below the primary installation script

  • Additional scripts are - gpu, postfix, fail2ban
  • I won't share these as they are a work in progress and not needed to get running

#!/bin/bash

# ========================================================================
# Proxmox VE 9.1 (Debian 13.2) PXE / Utility Node Setup Script
# ========================================================================
# This script performs a full initial configuration of a Proxmox host used
# as a PXE server, utility node, or general-purpose hypervisor in a home lab
# or small office environment.
#
# CRITICAL FIXES IN THIS VERSION (10.0.0):
# -----------------------------------------------------------------------
# This corrected version fixes several critical network configuration issues
# that caused SSH and GUI access loss when restarting LXC containers:
#
#   1. REMOVED dhcpcd5 entirely - it was conflicting with networking service
#      causing race conditions during network reconfiguration events triggered
#      by LXC container restarts. Only standard Debian networking is now used.
#
#   2. FIXED bridge slave configuration - eth0 now properly configured with
#      up/down commands to ensure the physical interface is always available
#      to the bridge during reconfigurations.
#
#   3. FIXED bridge STP settings - disabled STP and set proper bridge-maxwait
#      to prevent forwarding delays and topology convergence issues.
#
#   4. FIXED bash syntax error - added missing 'fi' statement in cron section
#      that would have caused script execution to fail.
#
#   5. FIXED timezone typo - corrected "Australia/Pert" to "Australia/Perth"
#
#   6. IMPROVED ICMP firewall rules - removed aggressive rate limiting that
#      could break established connections during network events.
#
#   7. SIMPLIFIED DNS configuration - removed conflicting static DNS entries,
#      now lets DHCP handle DNS automatically (static options available as
#      comments if needed).
#
# Major functions performed:
#   • Rewrites /etc/network/interfaces with corrected vmbr0 bridge config
#   • Properly configures eth0 as bridge slave with explicit up/down commands
#   • Enables IPv4/IPv6 forwarding and applies sysctl hardening
#   • Removes dhcpcd5 and disables the service to prevent conflicts
#   • Uses ONLY standard Debian networking (no dual network managers)
#   • Regenerates SSH host keys and applies a hardened sshd_config
#   • Installs required packages and removes unsafe defaults
#   • Configures nftables as the authoritative firewall (Proxmox firewall disabled)
#   • Deploys a hardened nftables ruleset with trusted-host access only
#   • Sets up nftables logging and log rotation
#   • Applies kernel, memory, and system tuning parameters
#   • Performs final diagnostics for networking and SSH
#
# Requirements:
#   • Designed for Proxmox VE 9.1 running Debian 13.2
#   • Safe for single-node or non-clustered Proxmox deployments
#   • Assumes primary NIC is "eth0" and main bridge is "vmbr0"
#   • Script is intended for fresh installations — it overwrites key configs
#   • iptables is NOT removed (required by Proxmox), but is not used
#   • nftables becomes the sole active firewall
#
# Expected Behavior After Running:
#   ✓ LXC containers can restart without breaking host connectivity
#   ✓ SSH remains accessible during network reconfigurations
#   ✓ Proxmox GUI remains accessible during network reconfigurations
#   ✓ No more need to reboot after LXC restarts
#   ✓ Single network manager eliminates race conditions
#   ✓ Stable bridge configuration survives network events
#
# Testing Recommendations:
#   1. After running script, verify: systemctl status networking
#   2. Confirm dhcpcd is gone: systemctl status dhcpcd (should be "not found")
#   3. Test LXC restart: pct restart <container-id>
#   4. Verify SSH still works after LXC restart
#   5. Check logs: journalctl -u networking -n 50
#
# Backup and Rollback:
#   • All original configs are backed up with timestamps
#   • Network config: /etc/network/interfaces.bak.<timestamp>
#   • SSH config: /etc/ssh/backup-<timestamp>/
#   • Rollback: restore backups and run: systemctl restart networking && reboot
#
# Version: 9.3.0
# Code review date: 03-02-2026
# ========================================================================

set -euo pipefail

# ------------------------------------------------------------------------
# Logging
info()  { echo -e "\033[0;32m[INFO]\033[0m $*"; }
warn() { echo -e "\033[0;33m[WARN]\033[0m $*" >&2; }
error(){ echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }

LOGFILE="/var/log/proxmox-setup-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$LOGFILE") 2>&1

BACKUP_TS="$(date +%Y%m%d-%H%M%S)"

# ------------------------------------------------------------------------
# Miscellaneous Configuration

# Timezone configuration - FIXED TYPO
info "Set the timezone manually to ensure consistency..."
timedatectl set-timezone Australia/Perth

# Install necessary packages prior to network changes
info "Installing additional packages - adjust as required..."
apt-get update -qq || warn "apt-get update failed, check DNS/network"
apt-get install -y -qq rsyslog mtr git curl wget build-essential dkms || error "Package installation failed"

# Remove packages as necessary prior to network changes
# ADDED dhcpcd5 to removal list to prevent conflicts
info "Removing packages - adjust as required..."
for pkg in ufw inetutils-telnet dhcpcd5; do
    if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "install ok installed"; then
        apt-get purge --auto-remove -y -qq "$pkg" || warn "$pkg failed to uninstall"
    fi
done

# Stop dhcpcd if it's running
systemctl stop dhcpcd 2>/dev/null || true
systemctl disable dhcpcd 2>/dev/null || true

# Create backups of sysctl
cp -a /etc/sysctl.d /etc/sysctl.d.bak.${BACKUP_TS}
cp -a /etc/apt/apt.conf.d /etc/apt/apt.conf.d.bak.${BACKUP_TS}
cp -a /etc/logrotate.conf /etc/logrotate.conf.bak.${BACKUP_TS}

# Skip downloading extra APT languages
info "Configuring APT to skip downloading extra language files..."
echo 'Acquire::Languages "none";' | tee /etc/apt/apt.conf.d/99-disable-languages

# Force APT to use IPv4 to avoid potential issues
info "Configuring APT to use IPv4..."
echo 'Acquire::ForceIPv4 "true";' | tee /etc/apt/apt.conf.d/99force-ipv4

# Configure kernel panic auto-reboot
info "Configuring kernel panic auto-reboot..."
echo "kernel.panic = 10" | tee /etc/sysctl.d/99-kernelpanic.conf
echo "kernel.panic_on_oops = 1" | tee -a /etc/sysctl.d/99-kernelpanic.conf
sysctl -p /etc/sysctl.d/99-kernelpanic.conf || warn "Failed to apply kernel panic sysctl settings"

# Increase system limits
info "Configuring system limits..."
echo "fs.inotify.max_user_watches = 1048576" | tee /etc/sysctl.d/99-maxwatches.conf
echo "* soft nofile 1048576" | tee /etc/security/limits.d/99-limits.conf
sysctl --system || warn "Failed to apply sysctl settings"

# Harden bash records - survive across sessions - crashes
info "Reconfiguring bash records..."
cat >/etc/profile.d/hardened-history.sh <<'EOF'
# Hardened Bash History
export HISTSIZE=10000
export HISTFILESIZE=100000
shopt -s histappend
export HISTTIMEFORMAT='%F %T '
PROMPT_COMMAND='history -a'
history -r
EOF

# Configure cron jobs for updates and maintenance
info "Configuring cron jobs for updates and maintenance..."

# Ensure the cron jobs exist
( crontab -l 2>/dev/null || true; \
  echo "0 1 * * * apt-get update -qq && apt-get -y -qq upgrade && apt-get -y -qq autoremove && apt-get -y -qq autoclean >> /var/log/apt-cron.log 2>&1" \
) | sort -u | crontab -

# Restart cron service depending on distro - FIXED MISSING 'fi'
if systemctl restart cron.service 2>/dev/null; then
  info "Cron restarted (cron.service)"
elif systemctl restart crond.service 2>/dev/null; then
  info "Cron restarted (crond.service)"
else
  error "Cron restart failed"
  exit 1
fi

# ------------------------------------------------------------------------
# Define primary NIC explicitly
PRIMARY_NIC="eth0"
VM_BRIDGE="vmbr0"

# ------------------------------------------------------------------------
# 1. Backup and write /etc/network/interfaces
info "Backing up and writing /etc/network/interfaces..."
cp /etc/network/interfaces /etc/network/interfaces.bak.${BACKUP_TS} || true

# CORRECTED NETWORK CONFIGURATION
# - Removed dhcpcd (causes conflicts with networking service)
# - Added proper bridge-slave configuration to eth0
# - Fixed bridge STP settings
# - Simplified DNS configuration (let DHCP handle it)
# - Added bridge-maxwait to prevent hangs
tee /etc/network/interfaces >/dev/null <<EOF
# Managed by setup-pxe-script.sh (${BACKUP_TS})
# CORRECTED VERSION - dhcpcd removed to prevent conflicts

auto lo
iface lo inet loopback

# Physical interface - bring up without IP configuration
# Critical: This ensures the bridge always has its physical port available
auto ${PRIMARY_NIC}
iface ${PRIMARY_NIC} inet manual
    up ip link set ${PRIMARY_NIC} up
    down ip link set ${PRIMARY_NIC} down

# Primary bridge - VMs and LXC containers use this
auto ${VM_BRIDGE}
iface ${VM_BRIDGE} inet dhcp
    bridge-ports ${PRIMARY_NIC}
    bridge-stp off
    bridge-fd 0
    bridge-maxwait 0
    # DNS is handled by DHCP - if you need static DNS, uncomment below:
    dns-nameservers 192.168.1.1
    dns-search braedach.com

iface ${VM_BRIDGE} inet6 auto
    accept_ra 2
    autoconf 1
    privext 0
    # DNS search domain for IPv6
    dns-search braedach.com
EOF

info "Interfaces file written with corrected configuration."

# ------------------------------------------------------------------------
# 2. Network sysctl tuning

# Enable IPv4 forwarding
info "Applying IPv4 sysctl tuning..."
tee /etc/sysctl.d/99-ipv4-${VM_BRIDGE}.conf >/dev/null <<EOF
net.ipv4.ip_forward=1
EOF

# Enable IPv6 forwarding and RA acceptance on bridge
info "Applying IPv6 sysctl tuning..."
tee /etc/sysctl.d/99-ipv6-${VM_BRIDGE}.conf >/dev/null <<EOF
net.ipv6.conf.all.forwarding=1
net.ipv6.conf.default.forwarding=1
net.ipv6.conf.${VM_BRIDGE}.accept_ra=2
net.ipv6.conf.${VM_BRIDGE}.autoconf=1
EOF

sysctl --system || warn "Failed to apply IPv4/IPv6 sysctl settings"

# ------------------------------------------------------------------------
# 3. Restart networking
info "Restarting networking service..."

# First, bring down the bridge cleanly
ip link set "${VM_BRIDGE}" down 2>/dev/null || true

# Restart networking service
if systemctl restart networking; then
  info "Networking service restarted successfully."
else
  warn "Networking restart failed — check configuration manually."
  warn "You may need to reboot to apply network changes."
fi

# Wait for bridge to come up
sleep 3

# Verify bridge is up
if ip link show "${VM_BRIDGE}" | grep -q "state UP"; then
  info "${VM_BRIDGE} is UP and configured."
else
  warn "${VM_BRIDGE} may not be up yet. Checking status..."
  ip link show "${VM_BRIDGE}" || true
fi

# ------------------------------------------------------------------------
# 4. SSH regeneration and hardening (drop-in based, idempotent, atomic)

info "Hardening SSH configuration..."

SSHD_DIR="/etc/ssh"
SSHD_CFG="${SSHD_DIR}/sshd_config"
SSHD_CUSTOM_DIR="${SSHD_DIR}/sshd_config.d"
SSHD_CUSTOM_CFG="${SSHD_CUSTOM_DIR}/99-hardened-custom.conf"
BACKUP_DIR="${SSHD_DIR}/backup-${BACKUP_TS}"

mkdir -p "${BACKUP_DIR}"
mkdir -p "${SSHD_CUSTOM_DIR}"

info "Backing up sshd_config and host keys..."
cp -a "${SSHD_CFG}" "${BACKUP_DIR}/sshd_config.bak" || true
for key in ssh_host_ed25519_key ssh_host_rsa_key ssh_host_ecdsa_key; do
    [[ -f "${SSHD_DIR}/${key}" ]] && mv "${SSHD_DIR}/${key}" "${BACKUP_DIR}/" || true
    [[ -f "${SSHD_DIR}/${key}.pub" ]] && mv "${SSHD_DIR}/${key}.pub" "${BACKUP_DIR}/" || true
done

info "Deleting old SSH host keys..."
rm -f /etc/ssh/ssh_host_*

info "Generating new ED25519 host key..."
ssh-keygen -t ed25519 -f "${SSHD_DIR}/ssh_host_ed25519_key" -N "" -o -a 100

info "Generating new RSA host key..."
ssh-keygen -t rsa -b 4096 -f "${SSHD_DIR}/ssh_host_rsa_key" -N "" -o

chmod 600 "${SSHD_DIR}/ssh_host_ed25519_key" "${SSHD_DIR}/ssh_host_rsa_key"
chmod 644 "${SSHD_DIR}/ssh_host_ed25519_key.pub" "${SSHD_DIR}/ssh_host_rsa_key.pub"

info "Ensuring main sshd_config includes custom configs..."

# Check if Include directive exists and add if missing
if ! grep -q "^Include ${SSHD_CUSTOM_DIR}/\*.conf" "${SSHD_CFG}"; then
    # Add Include directive at the top of the config file
    sed -i "1i Include ${SSHD_CUSTOM_DIR}/*.conf" "${SSHD_CFG}"
    info "Added Include directive to ${SSHD_CFG}"
fi

info "Writing hardened SSH configuration to ${SSHD_CUSTOM_CFG}..."

tee "${SSHD_CUSTOM_CFG}" >/dev/null <<EOF
# Hardened SSH Configuration
# Managed by base-setup script (${BACKUP_TS})
# This file is in sshd_config.d/ and will survive system updates

Protocol 2
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
HostbasedAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
LoginGraceTime 30
MaxAuthTries 4
MaxSessions 10

HostKey ${SSHD_DIR}/ssh_host_ed25519_key
HostKey ${SSHD_DIR}/ssh_host_rsa_key

AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
UseDNS no
PrintMotd no
PrintLastLog yes
AcceptEnv LANG LC_*

# Permit root password auth only from RFC1918 and link-local IPv6
Match Address 10.0.0.0/8,192.168.0.0/16,fe80::/10
  PermitRootLogin yes
  PasswordAuthentication yes
EOF

# Ensure the custom config has appropriate permissions
chmod 644 "${SSHD_CUSTOM_CFG}"

info "Validating sshd_config..."
if sshd -t; then
    info "sshd_config syntax OK, restarting ssh..."
    if systemctl restart ssh || systemctl restart sshd; then
        info "sshd restarted successfully."
    else
        error "systemctl restart ssh failed - check service status."
    fi
else
    error "sshd_config test failed - not restarting ssh."
    error "Backup available at: ${BACKUP_DIR}/sshd_config.bak"
fi

# ------------------------------------------------------------------------
# 5. Configure firewall - nftables only (Proxmox-safe)

info "Configuring nftables firewall..."

# Ensure nftables is installed and enabled
apt-get update -y
apt-get install -y nftables
systemctl enable --now nftables

# Disable Proxmox firewall (we use nftables instead)
pve-firewall stop || true
systemctl disable pve-firewall || true

# Ensure no iptables persistence is active
systemctl disable --now netfilter-persistent 2>/dev/null || true

# Deploy nftables loader
cat <<'EOF' > /etc/nftables.conf
#!/usr/sbin/nft -f
include "/etc/nftables-host.conf"
EOF

# Deploy nftables ruleset with improved ICMP handling
cat <<'EOF' > /etc/nftables-host.conf
#!/usr/sbin/nft -f

# Create and flush the filter table
add table inet filter
flush table inet filter

table inet filter {

    chain input {
        type filter hook input priority filter; policy drop;

        # Loopback - use iifname for compatibility
        iifname "lo" accept

        # Established / related connections (MUST be first)
        # This ensures existing SSH/GUI connections survive
        ct state established,related accept
        ct state invalid log prefix "INVALID-STATE: " drop

        # Trusted LAN hosts (full access) - place early
        ip saddr {
            192.168.1.1,      # Router
            192.168.1.15,     # Alex (reverse proxy)
            192.168.1.166,    # Management workstation 1
            192.168.1.252     # Management workstation 2
        } accept

        # SMTP relay (Mailgun) - with rate limiting to prevent abuse
        tcp dport 25 ct state new limit rate 100/minute burst 200 packets accept
        tcp dport 25 log prefix "SMTP-RATELIMIT: " drop

        # Proxmox GUI (8006) - LAN + link-local only
        tcp dport 8006 ip saddr 192.168.0.0/16 accept
        tcp dport 8006 ip6 saddr fe80::/10 accept
        tcp dport 8006 ip6 saddr fc00::/7 accept
        tcp dport 8006 log prefix "PROXMOX-DENY: " drop

        # DNS
        meta l4proto udp udp dport 53 accept
        meta l4proto tcp tcp dport 53 accept

        # DHCP client (Proxmox receiving from DHCP server)
        meta l4proto udp udp sport 67 udp dport 68 accept

        # DHCPv6 client
        meta l4proto udp udp dport 546 accept

        # mDNS
        meta l4proto udp udp dport 5353 accept

        # ICMP (v4 + v6) - IMPROVED: rate limit only applies to new connections
        # This prevents breaking established connections during network events
        ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded, parameter-problem } accept
        
        # ICMPv6 - essential for IPv6 operation (neighbor discovery, etc.)
        meta l4proto ipv6-icmp accept

        # SSH (management workstations only) - with brute-force protection
        tcp dport 22 ip saddr { 192.168.1.166, 192.168.1.252 } ct state new \
            limit rate 3/minute burst 5 packets accept
        tcp dport 22 ip saddr { 192.168.1.166, 192.168.1.252 } ct state new \
            log prefix "SSH-BRUTE: " drop

        # Deny SSH from all other sources
        tcp dport 22 log prefix "SSH-DENY: " drop

        # Generic rate-limited logging for other drops
        limit rate 5/minute burst 20 packets log prefix "DROP: "
    }

    chain forward {
        type filter hook forward priority filter; policy accept;
        # NOTE: Forward policy is ACCEPT to allow VM/LXC traffic
        # This is standard for Proxmox hypervisors
        # Add specific forward rules here if you need filtering
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}
EOF

# Test syntax of nftables-host file before applying
info "Validating nftables-host.conf syntax..."
if ! nft -c -f /etc/nftables-host.conf; then
    error "ERROR: nftables-host.conf configuration invalid - investigate..."
    exit 1
fi

# Apply the complete configuration
if ! nft -c -f /etc/nftables.conf; then
    error "ERROR: nftables.conf configuration invalid - investigate..."
    exit 1
fi

# Enable and start nftables service
systemctl enable nftables
systemctl restart nftables

# Verify it loaded
if systemctl is-active --quiet nftables; then
    info "nftables firewall applied successfully."
else
    error "nftables service failed to start!"
    journalctl -u nftables -n 20 --no-pager
    exit 1
fi

# Configure nftables logging
touch /var/log/nftables.log
cat <<'EOF' > /etc/rsyslog.d/30-nftables.conf
:msg, contains, "DROP:" -/var/log/nftables.log
:msg, contains, "SSH-DENY:" -/var/log/nftables.log
:msg, contains, "SSH-BRUTE:" -/var/log/nftables.log
:msg, contains, "SMTP-RATELIMIT:" -/var/log/nftables.log
:msg, contains, "PROXMOX-DENY:" -/var/log/nftables.log
:msg, contains, "INVALID-STATE:" -/var/log/nftables.log
& stop
EOF

info "nftables logging configured at /var/log/nftables.log"

# Log rotation for nftables logs
cat <<'EOF' >/etc/logrotate.d/nftables
/var/log/nftables.log {
    daily
    missingok
    rotate 7
    compress
    delaycompress
    notifempty
    create 0640 root adm
    postrotate
        systemctl reload rsyslog >/dev/null 2>&1 || true
    endscript
}
EOF

# Restart rsyslog to apply logging configuration
systemctl restart rsyslog

info "nftables firewall with logging and rotation configured."

# ------------------------------------------------------------------------
# 6. Final diagnostics
info "========================================="
info "Final Network Diagnostics:"
info "========================================="

info "Bridge status:"
ip addr show "${VM_BRIDGE}" || warn "Could not show ${VM_BRIDGE}"

info "IPv4 routes:"
ip route show || warn "Could not show IPv4 routes"

info "IPv6 routes:"
ip -6 route show || warn "Could not show IPv6 routes"

info "Listening services (SSH):"
ss -lntup | grep -E ':(22)\b' || warn "SSH not listening"

info "nftables ruleset loaded:"
nft list ruleset | head -n 20 || warn "Could not list nftables ruleset"

info "========================================="
info "✅ Network + SSH setup complete."
info "Log saved to ${LOGFILE}"
info "========================================="
info ""
info "IMPORTANT NOTES:"
info "1. dhcpcd has been REMOVED to prevent network conflicts"
info "2. Bridge configuration now uses standard Debian networking only"
info "3. Physical interface (${PRIMARY_NIC}) properly configured as bridge slave"
info "4. Bridge STP disabled to prevent forwarding delays"
info "5. LXC restarts should no longer cause network loss"
info ""
info "If you experience issues, check:"
info "  - systemctl status networking"
info "  - journalctl -u networking -n 50"
info "  - ip addr show ${VM_BRIDGE}"
info "  - /var/log/nftables.log for firewall drops"
info ""
info "Backups available at:"
info "  - /etc/network/interfaces.bak.${BACKUP_TS}"
info "  - ${SSHD_DIR}/backup-${BACKUP_TS}/"
info "========================================="

I have had no problem with this code and in relation to the names of the ethernet NIC's I adjust them on installation, I believe it's under advanced. Note you will need to adjust sections of this code to suit your hardware and needs.


LXC Configuration

My LXC's are based on Podman (the native repository version). You will definitely need to adjust this code to your needs as most people use docker. I do not for the following reasons

  • Podman does not use runc but crun and cgroups version 2
  • November crashed my systems, and I am not going down that road again
  • Podman does not use a daemon
  • It forced me to learn a completely new container system
  • Limited hardware - I cannot run up multiple LXCs even with Proxmox's new features
  • The base LXC is a Debian 13 container optimized for containers from Community scripts
  • You set it up when running the script on your server and can clone it as required
  • Additional scripts include - postfix, gpu and fail2ban
#!/bin/bash

# ============================================================
# Proxmox LXC Podman + Conditional Portainer CE Setup Script
# ============================================================
# Purpose:
#   - This script uses a Debian 13 LXC container
#   - Restructure the entire network 
#        - Change the proxy, flatten the Podman networks, reduce production servers
#        - Proxy on dedicated host - add the tunnel daemon later
#   - Completely build the firewall - switch to nftables - breaking change
#       - Switch to the trusted host model using nftables
#       - Requires the removal and purging of all iptables components and
#         carefully resetting nftables without touching podman created filters
#    - Manually creating the LXC out side community scripts broke the code
#       - Retest and fix the broken pieces.
#   - Remove all the unnecessary code
#
#   Version: 9.1.6
#
# Created: 14-11-2025
# Updated: 31-01-2026
# ============================================================

set -euo pipefail

# -----------------------------------------------------------------------
# Utility Functions
# -----------------------------------------------------------------------

info()  { echo -e "\033[0;32m[INFO]\033[0m $*"; }
warn()  { echo -e "\033[0;33m[WARN]\033[0m $*" >&2; }
error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }

# Master host server - hosts Portainer CE - all other host agents
MASTER_HOST=baden

# Dynamic firewall ports configuration
configure_firewall_ports() {
    local ports=("$@")

    info "Opening firewall ports: ${ports[*]}"

    {
        echo ""
        echo "# Dynamically added ports"
        for port in "${ports[@]}"; do
            echo "tcp dport $port accept"
        done
    } >> /etc/nftables-dynamic-ports.conf

    if nft -f /etc/nftables.conf; then
        info "Firewall updated successfully for ports: ${ports[*]}"
    else
        error "Failed to reload nftables after adding ports."
        return 1
    fi
}

# Check to ensure the system is set to nftables only
set_alt() {
    local name="$1"
    local target="$2"

    if sudo update-alternatives --set "$name" "$target"; then
        info "Set $name → $target"
    else
        warn "Failed to set $name to $target — investigate alternatives state"
    fi
}

# -----------------------------------------------------------------------
# 1. Base Packages
# -----------------------------------------------------------------------
info "Installing base packages..."
apt-get update -qq || warn "apt-get update failed, check DNS/network"
apt-get install -y -qq \
    bind9-dnsutils rsyslog sudo curl gpg net-tools apt-transport-https cron mtr git openssh-server openssh-client \
    podman podman-docker podman-compose

info "Removing undesired packages..."
for pkg in ufw inetutils-telnet iptables iptables-persistent netfilter-persistent; do
    if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "install ok installed"; then
        apt-get purge --auto-remove -y -qq "$pkg"
    fi
done


# -----------------------------------------------------------------------
# 2. Miscellaneous Configurations
# -----------------------------------------------------------------------
info "Configuring automatic updates via cron..."

# Ensure the cron job exists
( crontab -l 2>/dev/null || true; \
  echo "0 1 * * * apt-get update -qq && apt-get -y -qq upgrade && apt-get -y -qq autoremove && apt-get -y -qq autoclean" \
) | sort -u | crontab -

# Restart cron service depending on distro
if systemctl restart cron.service 2>/dev/null; then
  echo "Cron restarted (cron.service)"
elif systemctl restart crond.service 2>/dev/null; then
  echo "Cron restarted (crond.service)"
else
  echo "Cron restart failed" >&2
  exit 1
fi

# Harden the bash history
info "Harden the bash history - ensure nothing is lost..."
cat >/etc/profile.d/hardened-history.sh <<'EOF'
# Hardened Bash History
export HISTSIZE=10000
export HISTFILESIZE=100000
shopt -s histappend
export HISTTIMEFORMAT='%F %T '
PROMPT_COMMAND='history -a'
history -r
EOF

# Timezone configuration
info "Set the timezone manually to ensure consistency..."
timedatectl set-timezone Australia/Perth

# Ensure timezone is set in /etc/containers/containers.conf
CONF_FILE="/etc/containers/containers.conf"

# Create file if missing
if [ ! -f "$CONF_FILE" ]; then
    echo "[engine]" > "$CONF_FILE"
    echo 'tz = "Australia/Perth"' >> "$CONF_FILE"
else
    # Ensure [engine] section exists
    if ! grep -q "^

\[engine\]

" "$CONF_FILE"; then
        echo "[engine]" >> "$CONF_FILE"
    fi
    # Update or append tz line idempotently
    if grep -q "^tz" "$CONF_FILE"; then
        sed -i 's|^tz.*|tz = "Australia/Perth"|' "$CONF_FILE"
    else
        sed -i '/^

\[engine\]

/a tz = "Australia/Perth"' "$CONF_FILE"
    fi
fi


# Skip downloading extra APT languages
info "Configuring APT to skip downloading extra language files..."
echo 'Acquire::Languages "none";' | tee /etc/apt/apt.conf.d/99-disable-languages

# Force APT to use IPv4 to avoid potential issues
info "Configuring APT to use IPv4..."
echo 'Acquire::ForceIPv4 "true";' | tee /etc/apt/apt.conf.d/99force-ipv4

# -----------------------------------------------------------------------
# 3. Firewall configuration (nftables-native, Podman-safe)
# -----------------------------------------------------------------------

info "Configuring nftables firewall..."

# Create the nftables service file if missing
info "Create the dynamic nftables firewall file..."
touch /etc/nftables-dynamic-ports.conf || error "Failed to create /etc/nftables-dynamic-ports.conf"
chmod 644 /etc/nftables-dynamic-ports.conf || error "Failed to set permissions on /etc/nftables-dynamic-ports.conf"

info "Forcing the system to nftables - everything else to nft shm...."
set_alt iptables   /usr/sbin/iptables-nft
set_alt ip6tables  /usr/sbin/ip6tables-nft
set_alt arptables  /usr/sbin/arptables-nft
set_alt ebtables   /usr/sbin/ebtables-nft

# Host firewall rules (do NOT flush ruleset — Podman owns inet netavark)
cat <<'EOF' > /etc/nftables-host.conf
#!/usr/sbin/nft -f

# Create the table if it doesn't exist, then flush it
add table inet filter
flush table inet filter

table inet filter {

    chain input {
        type filter hook input priority filter; policy drop;

        # Loopback - use iifname for LXC compatibility
        iifname "lo" accept

        # Established / related connections (MUST be first)
        ct state established,related accept
        ct state invalid log prefix "INVALID-STATE: " drop

        # Trusted LAN hosts (only these can reach Baden)
        ip saddr {
            192.168.1.1,      # Router
            192.168.1.10,     # Proxmox
            192.168.1.15,     # Alex (reverse proxy)
            192.168.1.166,    # Management workstation 1
            192.168.1.252     # Management workstation 2
        } accept

        # Allow Podman container subnets to reach host services
        ip saddr 10.88.0.0/16 accept
        ip saddr 10.89.0.0/16 accept

        # DNS
        meta l4proto udp udp dport 53 accept
        meta l4proto tcp tcp dport 53 accept

        # DHCP client (LXC receiving from DHCP server)
        meta l4proto udp udp sport 67 udp dport 68 accept

        # mDNS
        meta l4proto udp udp dport 5353 accept

        # ICMP (v4 + v6)
        ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } \
            limit rate 10/second accept
        meta l4proto ipv6-icmp accept

        # SSH from Proxmox only, with brute-force protection
        tcp dport 22 ip saddr 192.168.1.10 ct state new \
            limit rate 3/minute burst 5 packets accept
        tcp dport 22 ip saddr 192.168.1.10 ct state new \
            log prefix "SSH-BRUTE: " drop

        # Deny SSH from all other sources
        tcp dport 22 log prefix "SSH-DENY: " drop

        # --- Dynamic port rules (auto-managed) ---
        # Only include if file exists to prevent startup failure
        include "/etc/nftables-dynamic-ports.conf"

        # Generic rate-limited logging for other drops
        limit rate 5/minute burst 20 packets log prefix "DROP: "
    }

    chain forward {
        type filter hook forward priority filter; policy accept;
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}
EOF

# Loader file — safe for Podman (NO flush ruleset)
cat <<'EOF' > /etc/nftables.conf
#!/usr/sbin/nft -f
include "/etc/nftables-host.conf"
EOF

info "Loading nftables ruleset..."
if ! nft -f /etc/nftables.conf; then
    error "Failed to load nftables ruleset; firewall not applied."
    exit 1
fi

systemctl enable --now nftables || error "nftables did not enable - please fix.."


# -----------------------------------------------------------------------
# 4. Firewall logging (rsyslog + logrotate)
# -----------------------------------------------------------------------

info "Configuring nftables logging..."

# Rsyslog routing for nftables log prefixes
cat <<'EOF' > /etc/rsyslog.d/nftables.conf
:msg, contains, "SSH-BRUTE"   -/var/log/nftables-dropped.log
:msg, contains, "SSH-DENY"    -/var/log/nftables-dropped.log
:msg, contains, "DROP:"       -/var/log/nftables-dropped.log
& stop
EOF

# Ensure log file exists with correct permissions
touch /var/log/nftables-dropped.log
chmod 0640 /var/log/nftables-dropped.log
chown root:adm /var/log/nftables-dropped.log

# Logrotate policy for nftables logs
cat <<'EOF' > /etc/logrotate.d/nftables
/var/log/nftables-dropped.log {
    daily
    missingok
    rotate 7
    compress
    delaycompress
    notifempty
    create 0640 root adm
    postrotate
        systemctl reload rsyslog >/dev/null 2>&1 || true
    endscript
}
EOF

# Restart rsyslog to apply new rules
systemctl restart rsyslog || warn "Failed to restart rsyslog"


# -----------------------------------------------------------------------
# 5. Sysctl configuration
# -----------------------------------------------------------------------
info "Configuring sysctl settings..."


# Enable IPv4 forwarding and RA acceptance on bridge
info "Applying IPv4 sysctl tuning..."
tee /etc/sysctl.d/99-ipv4-lxc.conf >/dev/null <<EOF
net.ipv4.ip_forward=0
EOF

# Enable IPv6 forwarding and RA acceptance on bridge
info "Applying IPv6 sysctl tuning..."
tee /etc/sysctl.d/99-ipv6-lxc.conf >/dev/null <<EOF
net.ipv6.conf.all.forwarding=0
net.ipv6.conf.eth0.accept_ra=2
EOF

sysctl --system || warn "Failed to apply IPv4/IPv6 sysctl settings"

warn "Some warning are normal due to the fact its a LXC...."

# -----------------------------------------------------------------------
# 6. Podman Registry Config & Network Setup
# -----------------------------------------------------------------------
info "Configuring Podman registries..."
mkdir -p /etc/containers/registries.conf.d
cat <<'EOF' > /etc/containers/registries.conf.d/99-unqualified.conf
# Managed by setup-lxc-script.sh (${BACKUP_TS})
unqualified-search-registries = ["docker.io", "quay.io", "ghcr.io", "registry.fedoraproject.org"]

[aliases]
# Map unqualified image names to fully qualified ones
"portainer/agent"      = "docker.io/portainer/agent"
"portainer/portainer-ce" = "docker.io/portainer/portainer-ce"
EOF

# Create Podman networks - modified.
info "Creating Podman network -"
podman network create app-net

# Restart the following services
systemctl start podman && systemctl enable podman
systemctl restart podman.socket  && systemctl enable podman.socket
# Enable Podman to restart all containers With Restart Policy Set To Always.
systemctl start podman-restart.service && systemctl enable podman-restart.service
systemctl restart podman.socket  && systemctl enable podman.socket
# Enable the auto update function that requires an additional label in the service stacks
systemctl enable podman-auto-update.service
systemctl enable podman-auto-update.timer
systemctl start podman-auto-update.timer
# Enable the clean function so that it runs on boot - there is no timer service
systemctl enable --now podman-clean-transient.service

# Ensure the cron job exists (deduplicated)
( crontab -l 2>/dev/null || true; \
  echo "0 21 * * * /usr/bin/systemctl start podman-clean-transient.service" \
) | sort -u | crontab -

# Restart cron service depending on distro
if systemctl restart cron.service 2>/dev/null; then
  echo "Cron restarted (cron.service)"
elif systemctl restart crond.service 2>/dev/null; then
  echo "Cron restarted (crond.service)"
else
  echo "Cron restart failed" >&2
  exit 1
fi


# -----------------------------------------------------------------------
# 7. Conditional Portainer Deployment
# -----------------------------------------------------------------------
HOST_SHORT=$(hostname -s || echo "unknown")
info "Detected short hostname: $HOST_SHORT"

if [[ "$HOST_SHORT" == "$MASTER_HOST" ]]; then
  info "Primary host — deploying Portainer CE..."

  # Ensure Podman does not reuse CE name
  podman rm -f portainer-ce >/dev/null 2>&1 || true

  # Build base create command as an array (safer than eval)
  podman create --name portainer-ce \
       --restart=always \
       --privileged \
       --no-healthcheck \
       -p 9000:9000 -p 9443:9443 \
       -v /run/podman/podman.sock:/var/run/docker.sock \
       -v portainer_data:/data \
       docker.io/portainer/portainer-ce:latest

  # Generate systemd unit
  podman generate systemd --name portainer-ce --files --new
  mv container-portainer-ce.service /etc/systemd/system/portainer-ce.service

  # Enable and start
  systemctl daemon-reexec && systemctl daemon-reload
  systemctl start portainer-ce.service && systemctl enable --now portainer-ce.service
  configure_firewall_ports 9000 9443
  info "Portainer CE deployed."

else
  info "Secondary host — deploying Portainer Agent (requires privileged)..."

  # Ensure Podman does not reuse CE name
  podman rm -f portainer-agent >/dev/null 2>&1 || true

  # Agent requires access to the container runtime socket and listens on 9001 - requires privileged
  podman create --name portainer-agent \
  --restart=always \
  --privileged \
  --no-healthcheck \
  -p 9001:9001 \
  -v /run/podman/podman.sock:/var/run/docker.sock \
  -v portainer_agent_data:/var/lib/docker/volumes \
  -v /:/host \
  docker.io/portainer/agent:latest


  # Generate systemd unit
  podman generate systemd --name portainer-agent --files --new
  mv container-portainer-agent.service /etc/systemd/system/portainer-agent.service

  # Enable and start
  systemctl daemon-reexec && systemctl daemon-reload
  systemctl start portainer-agent.service && systemctl enable --now portainer-agent.service
  configure_firewall_ports 9001
  info "Portainer Agent deployed."
fi


# -----------------------------------------------------------------------
# 8. SSH Configuration Hardening
# -----------------------------------------------------------------------

info "Hardening SSH configuration..."

BACKUP_TS=$(date +"%Y%m%d-%H%M%S")
SSHD_DIR="/etc/ssh"
SSHD_CFG="${SSHD_DIR}/sshd_config"
SSHD_CUSTOM_DIR="${SSHD_DIR}/sshd_config.d"
SSHD_CUSTOM_CFG="${SSHD_CUSTOM_DIR}/99-hardened-custom.conf"
BACKUP_DIR="${SSHD_DIR}/backup-${BACKUP_TS}"

mkdir -p "${BACKUP_DIR}"
mkdir -p "${SSHD_CUSTOM_DIR}"

info "Backing up sshd_config and host keys..."
cp -a "${SSHD_CFG}" "${BACKUP_DIR}/sshd_config.bak" || true
for key in ssh_host_ed25519_key ssh_host_rsa_key ssh_host_ecdsa_key; do
    [[ -f "${SSHD_DIR}/${key}" ]] && mv "${SSHD_DIR}/${key}" "${BACKUP_DIR}/" || true
    [[ -f "${SSHD_DIR}/${key}.pub" ]] && mv "${SSHD_DIR}/${key}.pub" "${BACKUP_DIR}/" || true
done

info "Deleting old SSH host keys..."
rm -f /etc/ssh/ssh_host_*

info "Generating new ED25519 host key..."
ssh-keygen -t ed25519 -f "${SSHD_DIR}/ssh_host_ed25519_key" -N "" -o -a 100

info "Generating new RSA host key..."
ssh-keygen -t rsa -b 4096 -f "${SSHD_DIR}/ssh_host_rsa_key" -N "" -o

chmod 600 "${SSHD_DIR}/ssh_host_ed25519_key" "${SSHD_DIR}/ssh_host_rsa_key"
chmod 644 "${SSHD_DIR}/ssh_host_ed25519_key.pub" "${SSHD_DIR}/ssh_host_rsa_key.pub"

info "Ensuring main sshd_config includes custom configs..."

# Check if Include directive exists and add if missing
if ! grep -q "^Include ${SSHD_CUSTOM_DIR}/\*.conf" "${SSHD_CFG}"; then
    # Add Include directive at the top of the config file
    sed -i "1i Include ${SSHD_CUSTOM_DIR}/*.conf" "${SSHD_CFG}"
    info "Added Include directive to ${SSHD_CFG}"
fi

info "Writing hardened SSH configuration to ${SSHD_CUSTOM_CFG}..."

tee "${SSHD_CUSTOM_CFG}" >/dev/null <<EOF
# Hardened SSH Configuration
# Managed by base-setup script (${BACKUP_TS})
# This file is in sshd_config.d/ and will survive system updates

Protocol 2
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
HostbasedAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
LoginGraceTime 30
MaxAuthTries 4
MaxSessions 10

HostKey ${SSHD_DIR}/ssh_host_ed25519_key
HostKey ${SSHD_DIR}/ssh_host_rsa_key

AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
UseDNS no
PrintMotd no
PrintLastLog yes
AcceptEnv LANG LC_*

# Permit root password auth only from RFC1918 and link-local IPv6
Match Address 10.0.0.0/8,192.168.0.0/16,fe80::/10
  PermitRootLogin yes
  PasswordAuthentication yes
EOF

# Ensure the custom config has appropriate permissions
chmod 644 "${SSHD_CUSTOM_CFG}"

info "Validating sshd_config..."
if sshd -t; then
    info "sshd_config syntax OK, restarting ssh..."
    if systemctl restart ssh || systemctl restart sshd; then
        info "sshd restarted successfully."
    else
        error "systemctl restart ssh failed - check service status."
    fi
else
    error "sshd_config test failed - not restarting ssh."
    error "Backup available at: ${BACKUP_DIR}/sshd_config.bak"
fi

# -----------------------------------------------------------------------
# 9. Final Notes
# -----------------------------------------------------------------------
info "  Deployment complete."
info "  Portainer stacks still need watchtower"
info "  Portainer and agents will auto update"

warn "  Please run the LXC-GPU code post reboot after this script has completed."
warn "  Please ensure that the LXC container configuration file meets prototype kept on file"

warn "  Please reboot the system to ensure all settings take full effect."

This should get you up and running but as above you will definitely need to edit this script depending on your container system, the SSH rules, the trusted hosts and so forth.

I have proofread this multiple times - and found additional errors - fixed
I have rebooted multiple times and found no major issues
I have port scanned multiple times.


LXC Configuration - Docker

The result of further testing. Added as an option

#!/bin/bash

# ============================================================
# Proxmox LXC Docker + Docker Compose
# ============================================================
# Purpose:
#   - This script uses a Debian 13 LXC container
#   - Restructure the entire network 
#        - Docker deployment
#        - Proxy on dedicated host
#   - Completely build the firewall - switch to nftables
#       - Switch to the trusted host model using nftables
#       - Requires the removal and purging of all iptables components and
#         carefully resetting nftables without touching podman created filters
#    - Manually creating the LXC out side community scripts broke the code
#       - Retest and fix the broken pieces.
#   - Remove all the unnecessary code
#
#   Version: 1.0.6
# Created: 04-01-2026
# Updated: 31-01-2026
# ============================================================

set -euo pipefail

# -----------------------------------------------------------------------
# Utility Functions
# -----------------------------------------------------------------------

info()  { echo -e "\033[0;32m[INFO]\033[0m $*"; }
warn()  { echo -e "\033[0;33m[WARN]\033[0m $*" >&2; }
error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }


# Dynamic firewall ports configuration
configure_firewall_ports() {
    local ports=("$@")

    info "Opening firewall ports: ${ports[*]}"

    {
        echo ""
        echo "# Dynamically added ports"
        for port in "${ports[@]}"; do
            echo "tcp dport $port accept"
        done
    } >> /etc/nftables-dynamic-ports.conf

    if nft -f /etc/nftables.conf; then
        info "Firewall updated successfully for ports: ${ports[*]}"
    else
        error "Failed to reload nftables after adding ports."
        return 1
    fi
}



# -----------------------------------------------------------------------
# 1. Base Packages
# -----------------------------------------------------------------------
info "Installing base packages - excluding docker respositories..."
apt-get update -qq || warn "apt-get update failed, check DNS/network"
apt-get install -y -qq \
    bind9-dnsutils rsyslog sudo curl gpg net-tools apt-transport-https cron mtr git openssh-server openssh-client

info "Removing undesired packages..."
for pkg in ufw inetutils-telnet iptables iptables-persistent netfilter-persistent; do
    if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "install ok installed"; then
        apt-get purge --auto-remove -y -qq "$pkg"
    fi
done


# -----------------------------------------------------------------------
# 2. Miscellaneous Configurations
# -----------------------------------------------------------------------
info "Configuring automatic updates via cron..."

# Ensure the cron job exists
( crontab -l 2>/dev/null || true; \
  echo "0 1 * * * apt-get update -qq && apt-get -y -qq upgrade && apt-get -y -qq autoremove && apt-get -y -qq autoclean" \
) | sort -u | crontab -

# Restart cron service depending on distro
if systemctl restart cron.service 2>/dev/null; then
  echo "Cron restarted (cron.service)"
elif systemctl restart crond.service 2>/dev/null; then
  echo "Cron restarted (crond.service)"
else
  echo "Cron restart failed" >&2
  exit 1
fi

# Harden the bash history
info "Harden the bash history - ensure nothing is lost..."
cat >/etc/profile.d/hardened-history.sh <<'EOF'
# Hardened Bash History
export HISTSIZE=10000
export HISTFILESIZE=100000
shopt -s histappend
export HISTTIMEFORMAT='%F %T '
PROMPT_COMMAND='history -a'
history -r
EOF

# Timezone configuration
info "Set the timezone manually to ensure consistency..."
timedatectl set-timezone Australia/Perth

# Skip downloading extra APT languages
info "Configuring APT to skip downloading extra language files..."
echo 'Acquire::Languages "none";' | tee /etc/apt/apt.conf.d/99-disable-languages

# Force APT to use IPv4 to avoid potential issues
info "Configuring APT to use IPv4..."
echo 'Acquire::ForceIPv4 "true";' | tee /etc/apt/apt.conf.d/99force-ipv4

# -----------------------------------------------------------------------
# 3. Firewall configuration (nftables-native, Docker-aware)
# -----------------------------------------------------------------------

info "Configuring nftables firewall..."
info "Create the dynamic nftables firewall file..."
touch /etc/nftables-dynamic-ports.conf

# Host firewall rules (Docker-aware; does not interfere with iptables-nft rules)
cat <<'EOF' > /etc/nftables-host.conf
#!/usr/sbin/nft -f

table inet filter {

    chain input {
        type filter hook input priority 0; policy drop;

        # Loopback
        iif "lo" accept

        # Established / related
        ct state established,related accept

        # Trusted LAN hosts
        ip saddr {
            192.168.1.1,
            192.168.1.10,
            192.168.1.15,
            192.168.1.166,
            192.168.1.252
        } accept

        # Allow Docker container subnets
        ip saddr 172.16.0.0/12 accept

        # DNS
        udp dport 53 accept
        tcp dport 53 accept

        # DHCP client
        udp sport 67 udp dport 68 accept
        udp sport 68 udp dport 67 accept

        # mDNS
        udp dport 5353 accept
        
        # ICMP (v4 + v6)
        ip protocol icmp accept
        ip6 nexthdr icmpv6 accept

        # SSH brute-force protection (Proxmox only)
        tcp dport 22 ip saddr 192.168.1.10 ct state new limit rate 3/minute burst 5 packets accept
        tcp dport 22 ip saddr 192.168.1.10 log prefix "SSH-BRUTE: " level info
        tcp dport 22 ip saddr 192.168.1.10 drop

        # Deny SSH from all others
        tcp dport 22 log prefix "SSH-DENY: " level info
        tcp dport 22 drop

        # --- Dynamic port rules (auto-managed) ---
        include "/etc/nftables-dynamic-ports.conf"

        # Generic rate-limited logging
        limit rate 5/minute burst 20 packets log prefix "DROP: " level info

        drop
    }

    chain forward {
        type filter hook forward priority 0; policy accept;
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }
}
EOF

# Loader file — safe for Docker/Podman (NO flush ruleset)
cat <<'EOF' > /etc/nftables.conf
#!/usr/sbin/nft -f
include "/etc/nftables-host.conf"
EOF

info "Loading nftables ruleset..."
if ! nft -f /etc/nftables.conf; then
    error "Failed to load nftables ruleset; firewall not applied."
    exit 1
fi


# -----------------------------------------------------------------------
# 4. Firewall logging (rsyslog + logrotate)
# -----------------------------------------------------------------------

info "Configuring nftables logging..."

# Rsyslog routing for nftables log prefixes
cat <<'EOF' > /etc/rsyslog.d/nftables.conf
:msg, contains, "SSH-BRUTE"   -/var/log/nftables-dropped.log
:msg, contains, "SSH-DENY"    -/var/log/nftables-dropped.log
:msg, contains, "DROP:"       -/var/log/nftables-dropped.log
& stop
EOF

# Ensure log file exists with correct permissions
touch /var/log/nftables-dropped.log
chmod 0640 /var/log/nftables-dropped.log
chown root:adm /var/log/nftables-dropped.log

# Logrotate policy for nftables logs
cat <<'EOF' > /etc/logrotate.d/nftables
/var/log/nftables-dropped.log {
    daily
    missingok
    rotate 7
    compress
    delaycompress
    notifempty
    create 0640 root adm
    postrotate
        systemctl reload rsyslog >/dev/null 2>&1 || true
    endscript
}
EOF

# Restart rsyslog to apply new rules
systemctl restart rsyslog || warn "Failed to restart rsyslog"

# -----------------------------------------------------------------------
# 5. Sysctl configuration
# -----------------------------------------------------------------------
info "Configuring sysctl settings..."


# Enable IPv4 forwarding and RA acceptance on bridge
info "Applying IPv4 sysctl tuning..."
tee /etc/sysctl.d/99-ipv4-lxc.conf >/dev/null <<EOF
net.ipv4.ip_forward=0
EOF

# Enable IPv6 forwarding and RA acceptance on bridge
info "Applying IPv6 sysctl tuning..."
tee /etc/sysctl.d/99-ipv6-lxc.conf >/dev/null <<EOF
net.ipv6.conf.all.forwarding=0
net.ipv6.conf.eth0.accept_ra=2
EOF

sysctl --system || warn "Failed to apply IPv4/IPv6 sysctl settings"

warn "Some warning are normal due to the fact its a LXC...."


# -----------------------------------------------------------------------
# 6. Docker Engine + Compose Installation & Validation
# -----------------------------------------------------------------------
info "Installing Docker Engine and Docker Compose..."

# Install prerequisites
apt-get update -y
apt-get install -y ca-certificates curl gnupg lsb-release

# Create keyring directory
install -m 0755 -d /etc/apt/keyrings

# Install Docker GPG key
if curl -fsSL https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg \
    | gpg --dearmor -o /etc/apt/keyrings/docker.gpg; then
    info "Docker GPG key installed."
else
    error "Failed to install Docker GPG key."
    exit 1
fi

chmod a+r /etc/apt/keyrings/docker.gpg

# Add Docker repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") \
  $(lsb_release -cs) stable" \
  > /etc/apt/sources.list.d/docker.list

apt-get update -y

# Install Docker Engine + CLI + Compose plugin
if apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; then
    info "Docker Engine and Docker Compose installed."
else
    error "Docker installation failed."
    exit 1
fi

# -----------------------------------------------------------------------
# Docker Diagnostics

info "Running Docker diagnostics..."

DOCKER_OK=true

if ! systemctl enable --now docker; then
    warn "Docker service failed to startopenssh-server openssh-client."
    DOCKER_OK=false
fi

if ! docker info >/dev/null 2>&1; then
    warn "Docker daemon not responding to 'docker info'."
    DOCKER_OK=false
else
    info "Docker daemon is responding."
fi

if ! docker run --rm hello-world >/dev/null 2>&1; then
    warn "Docker test container failed to run."
    DOCKER_OK=false
else
    info "Docker test container executed successfully."
fi

if [ "$DOCKER_OK" = false ]; then
    error "Docker diagnostics reported issues. Review logs before continuing."
else
    info "Docker diagnostics passed."
fi

info "Create the base docker network bridge ..."
docker network create \
  --driver bridge \
  --subnet 172.20.0.0/16 \
  --gateway 172.20.0.1 \
  --ipv6=false \
  app-net


# -----------------------------------------------------------------------
# Portainer Agent Deployment (Docker)

info "Deploying Portainer Agent..."

# Remove any existing container
docker rm -f portainer-agent >/dev/null 2>&1 || true

# Create Portainer Agent container
if docker create \
    --name portainer-agent \
    --restart=always \
    --privileged \
    -p 9001:9001 \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v /:/host \
    -v portainer_agent_data:/var/lib/docker/volumes \
    portainer/agent:latest; then
    info "Portainer Agent container created."
else
    error "Failed to create Portainer Agent container."
    exit 1
fi

# Generate systemd unit
docker run --rm \
    -v /etc/systemd/system:/systemd \
    portainer/agent:latest \
    >/dev/null 2>&1 || true

# Manual systemd creation (Docker does not auto-generate like Podman)
cat <<EOF >/etc/systemd/system/portainer-agent.service
[Unit]
Description=Portainer Agent
After=docker.service
Requires=docker.service

[Service]
Restart=always
ExecStart=/usr/bin/docker start -a portainer-agent
ExecStop=/usr/bin/docker stop -t 10 portainer-agent

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now portainer-agent.service

# Firewall rule (function assumed to exist)
configure_firewall_ports 9001

info "Portainer Agent deployed successfully."


# -----------------------------------------------------------------------
# 7. SSH Configuration Hardening
# -----------------------------------------------------------------------

info "Hardening SSH configuration..."

BACKUP_TS=$(date +"%Y%m%d-%H%M%S")
SSHD_DIR="/etc/ssh"
SSHD_CFG="${SSHD_DIR}/sshd_config"
SSHD_CUSTOM_DIR="${SSHD_DIR}/sshd_config.d"
SSHD_CUSTOM_CFG="${SSHD_CUSTOM_DIR}/99-hardened-custom.conf"
BACKUP_DIR="${SSHD_DIR}/backup-${BACKUP_TS}"

mkdir -p "${BACKUP_DIR}"
mkdir -p "${SSHD_CUSTOM_DIR}"

info "Backing up sshd_config and host keys..."
cp -a "${SSHD_CFG}" "${BACKUP_DIR}/sshd_config.bak" || true
for key in ssh_host_ed25519_key ssh_host_rsa_key ssh_host_ecdsa_key; do
    [[ -f "${SSHD_DIR}/${key}" ]] && mv "${SSHD_DIR}/${key}" "${BACKUP_DIR}/" || true
    [[ -f "${SSHD_DIR}/${key}.pub" ]] && mv "${SSHD_DIR}/${key}.pub" "${BACKUP_DIR}/" || true
done

info "Deleting old SSH host keys..."
rm -f /etc/ssh/ssh_host_*

info "Generating new ED25519 host key..."
ssh-keygen -t ed25519 -f "${SSHD_DIR}/ssh_host_ed25519_key" -N "" -o -a 100

info "Generating new RSA host key..."
ssh-keygen -t rsa -b 4096 -f "${SSHD_DIR}/ssh_host_rsa_key" -N "" -o

chmod 600 "${SSHD_DIR}/ssh_host_ed25519_key" "${SSHD_DIR}/ssh_host_rsa_key"
chmod 644 "${SSHD_DIR}/ssh_host_ed25519_key.pub" "${SSHD_DIR}/ssh_host_rsa_key.pub"

info "Ensuring main sshd_config includes custom configs..."

# Check if Include directive exists and add if missing
if ! grep -q "^Include ${SSHD_CUSTOM_DIR}/\*.conf" "${SSHD_CFG}"; then
    # Add Include directive at the top of the config file
    sed -i "1i Include ${SSHD_CUSTOM_DIR}/*.conf" "${SSHD_CFG}"
    info "Added Include directive to ${SSHD_CFG}"
fi

info "Writing hardened SSH configuration to ${SSHD_CUSTOM_CFG}..."

tee "${SSHD_CUSTOM_CFG}" >/dev/null <<EOF
# Hardened SSH Configuration
# Managed by base-setup script (${BACKUP_TS})
# This file is in sshd_config.d/ and will survive system updates

Protocol 2
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
HostbasedAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
LoginGraceTime 30
MaxAuthTries 4
MaxSessions 10

HostKey ${SSHD_DIR}/ssh_host_ed25519_key
HostKey ${SSHD_DIR}/ssh_host_rsa_key

AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
UseDNS no
PrintMotd no
PrintLastLog yes
AcceptEnv LANG LC_*

# Permit root password auth only from RFC1918 and link-local IPv6
Match Address 10.0.0.0/8,192.168.0.0/16,fe80::/10
  PermitRootLogin yes
  PasswordAuthentication yes
EOF

# Ensure the custom config has appropriate permissions
chmod 644 "${SSHD_CUSTOM_CFG}"

info "Validating sshd_config..."
if sshd -t; then
    info "sshd_config syntax OK, restarting ssh..."
    if systemctl restart ssh || systemctl restart sshd; then
        info "sshd restarted successfully."
    else
        error "systemctl restart ssh failed - check service status."
    fi
else
    error "sshd_config test failed - not restarting ssh."
    error "Backup available at: ${BACKUP_DIR}/sshd_config.bak"
fi


# -----------------------------------------------------------------------
# 8. Final Notes
# -----------------------------------------------------------------------
info "  Deployment complete."
info "  Docker Engine and compose deployed..."

warn "  Please run the LXC-GPU code post reboot after this script has completed."

warn "  Please reboot the system to ensure all settings take full effect."

I do not use Docker, so I am not spending any more time on this script.
If you do use Docker your attention is drawn to the following

Docker with nftables
How Docker works with nftables

The proxy configuration has been shared under another post located here

Zoraxy Reverse Proxy Setup
This script has been created to automate the process of creating the proxy Learnings: * Debian 13 uses nftables by default so I made a mess of it using iptables * Backup the LXC * Undo all the incorrect work and rebuild it using nftables * Test the code again * Put a reworking note

Any how sharing is caring and this might help someone

#enoughsaid