bootstrap-homelab-k3s.sh
#!/usr/bin/env bash
set -Eeuo pipefail
# ==========================================================
# Mr Walia Massive Homelab Bootstrap Script
# Target: Fresh Ubuntu Server / Ubuntu laptop homelab
# Includes:
# - System update, tools, Docker, K3s, Cockpit
# - Static IP + Wi-Fi Netplan
# - No sleep on laptop lid close
# - Full logging to /var/log/lw-homelab-bootstrap/latest.log
# - Optional HDD wipe/format/mount to /srv/storage
# - Samba Windows share for /srv/storage
# - Portainer, Homepage, Jellyfin, Kavita Docker stack
# - Helper commands, aliases, MOTD login banner
# ==========================================================
# -------------------------
# User editable variables
# -------------------------
HOSTNAME="${HOSTNAME:-homelabs}"
TIMEZONE="${TIMEZONE:-Asia/Kolkata}"
STATIC_IP="${STATIC_IP:-192.168.1.18/24}"
GATEWAY_IP="${GATEWAY_IP:-192.168.1.1}"
DNS_1="${DNS_1:-1.1.1.1}"
DNS_2="${DNS_2:-8.8.8.8}"
NODE_IP="${STATIC_IP%%/*}"
# Wi-Fi support. Ethernet is better, but Wi-Fi is supported.
WIFI_SSID="${WIFI_SSID:-}"
WIFI_PASSWORD="${WIFI_PASSWORD:-}"
DISABLE_CLOUD_INIT_NETWORK="${DISABLE_CLOUD_INIT_NETWORK:-true}"
# Core installs
INSTALL_BASE_TOOLS="${INSTALL_BASE_TOOLS:-true}"
INSTALL_DOCKER="${INSTALL_DOCKER:-true}"
INSTALL_K3S="${INSTALL_K3S:-true}"
INSTALL_HELM="${INSTALL_HELM:-true}"
INSTALL_K9S="${INSTALL_K9S:-true}"
INSTALL_COCKPIT="${INSTALL_COCKPIT:-true}"
ENABLE_UFW="${ENABLE_UFW:-true}"
AUTO_REBOOT="${AUTO_REBOOT:-true}"
# K3s settings
K3S_CHANNEL="${K3S_CHANNEL:-stable}"
K3S_EXTRA_ARGS="${K3S_EXTRA_ARGS:---write-kubeconfig-mode=644 --node-ip=${NODE_IP} --advertise-address=${NODE_IP}}"
# Storage/HDD settings
# SAFE DEFAULT: this script will NOT wipe any disk unless both CONFIGURE_STORAGE=true and WIPE_STORAGE=true.
CONFIGURE_STORAGE="${CONFIGURE_STORAGE:-false}"
WIPE_STORAGE="${WIPE_STORAGE:-false}"
STORAGE_DISK="${STORAGE_DISK:-/dev/sdb}"
STORAGE_PARTITION="${STORAGE_PARTITION:-${STORAGE_DISK}1}"
STORAGE_MOUNT="${STORAGE_MOUNT:-/srv/storage}"
STORAGE_LABEL="${STORAGE_LABEL:-HOMELAB_STORAGE}"
STORAGE_RESERVED_PERCENT="${STORAGE_RESERVED_PERCENT:-1}"
CREATE_STORAGE_FOLDERS="${CREATE_STORAGE_FOLDERS:-true}"
# Samba settings
INSTALL_SAMBA="${INSTALL_SAMBA:-true}"
SAMBA_SHARE_NAME="${SAMBA_SHARE_NAME:-HomelabStorage}"
SAMBA_PASSWORD="${SAMBA_PASSWORD:-}"
# Docker app stack settings
INSTALL_PORTAINER="${INSTALL_PORTAINER:-true}"
INSTALL_MEDIA_STACK="${INSTALL_MEDIA_STACK:-true}"
INSTALL_UPTIME_KUMA="${INSTALL_UPTIME_KUMA:-false}"
COMPOSE_ROOT="${COMPOSE_ROOT:-/opt/homelab/compose}"
MEDIA_STACK_DIR="${MEDIA_STACK_DIR:-${COMPOSE_ROOT}/media-stack}"
PORTAINER_PORT="${PORTAINER_PORT:-9443}"
HOMEPAGE_PORT="${HOMEPAGE_PORT:-3001}"
JELLYFIN_PORT="${JELLYFIN_PORT:-8096}"
KAVITA_PORT="${KAVITA_PORT:-5001}"
UPTIME_KUMA_PORT="${UPTIME_KUMA_PORT:-3002}"
# -------------------------
# Logging setup
# -------------------------
LOG_DIR="/var/log/lw-homelab-bootstrap"
mkdir -p "${LOG_DIR}"
LOG_FILE="${LOG_DIR}/bootstrap-$(date +%Y%m%d-%H%M%S).log"
ln -sfn "${LOG_FILE}" "${LOG_DIR}/latest.log"
exec > >(tee -a "${LOG_FILE}") 2>&1
GREEN="\033[0;32m"
YELLOW="\033[1;33m"
RED="\033[0;31m"
BLUE="\033[0;34m"
NC="\033[0m"
log() { echo -e "${GREEN}[INFO $(date '+%F %T')]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN $(date '+%F %T')]${NC} $*"; }
err() { echo -e "${RED}[ERROR $(date '+%F %T')]${NC} $*"; }
section() {
echo
echo -e "${BLUE}============================================================${NC}"
echo -e "${BLUE} $*${NC}"
echo -e "${BLUE}============================================================${NC}"
}
trap 'err "Script failed at line ${LINENO}. Command: ${BASH_COMMAND}"; err "Check log file: ${LOG_FILE}"' ERR
run() {
echo
echo -e "${YELLOW}>>> Running:${NC} $*"
"$@"
echo -e "${GREEN}<<< Done:${NC} $*"
}
install_if_available() {
local pkg="$1"
if apt-cache show "$pkg" >/dev/null 2>&1; then
log "Installing package: ${pkg}"
DEBIAN_FRONTEND=noninteractive apt-get install -y "$pkg"
else
warn "Package not available in apt repo, skipping: $pkg"
fi
}
append_line_if_missing() {
local line="$1"
local file="$2"
grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file"
}
print_system_report() {
section "SYSTEM REPORT"
echo "Date/time: $(date)"
echo "Hostname: $(hostname)"
echo "Kernel: $(uname -a)"
echo
echo "OS release:"
cat /etc/os-release || true
echo
echo "CPU:"
lscpu | sed -n '1,25p' || true
echo
echo "Memory:"
free -h || true
echo
echo "Disk usage:"
df -h || true
echo
echo "Block devices:"
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINTS,MODEL || true
echo
echo "Network addresses:"
ip -br addr || true
echo
echo "Default route:"
ip route show default || true
echo
echo "Temperatures, if sensors are available:"
sensors 2>/dev/null || true
}
require_root() {
if [[ "${EUID}" -ne 0 ]]; then
err "Run with sudo: sudo bash bootstrap-homelab-full.sh"
exit 1
fi
}
# -------------------------
# Start
# -------------------------
section "STARTING MASSIVE HOMELAB BOOTSTRAP"
echo "Main log file: ${LOG_FILE}"
echo "Latest log symlink: ${LOG_DIR}/latest.log"
echo "Everything shown in terminal is also saved to the log file."
require_root
ADMIN_USER="${SUDO_USER:-$(logname 2>/dev/null || echo root)}"
ADMIN_HOME="$(getent passwd "${ADMIN_USER}" | cut -d: -f6 || echo /root)"
ADMIN_UID="$(id -u "${ADMIN_USER}" 2>/dev/null || echo 1000)"
ADMIN_GID="$(id -g "${ADMIN_USER}" 2>/dev/null || echo 1000)"
log "Admin user detected: ${ADMIN_USER}"
log "Admin home detected: ${ADMIN_HOME}"
log "Admin UID:GID detected: ${ADMIN_UID}:${ADMIN_GID}"
# -------------------------
# OS check
# -------------------------
section "CHECKING OPERATING SYSTEM"
source /etc/os-release
UBUNTU_CODENAME_DETECTED="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}"
log "Detected OS: ${PRETTY_NAME:-unknown}"
log "Detected codename: ${UBUNTU_CODENAME_DETECTED:-unknown}"
if [[ "${ID:-}" != "ubuntu" ]]; then
warn "This script is designed for Ubuntu. Detected ID=${ID:-unknown}. Continuing, but some steps may fail."
fi
print_system_report
# -------------------------
# Hostname/timezone
# -------------------------
section "SETTING HOSTNAME AND TIMEZONE"
run hostnamectl set-hostname "${HOSTNAME}"
run timedatectl set-timezone "${TIMEZONE}"
log "Hostname set to: ${HOSTNAME}"
log "Timezone set to: ${TIMEZONE}"
# -------------------------
# Apt update/upgrade
# -------------------------
section "UPDATING SYSTEM"
run apt-get update -y
run env DEBIAN_FRONTEND=noninteractive apt-get upgrade -y
run apt-get autoremove -y
run apt-get autoclean -y
# -------------------------
# Base tools
# -------------------------
if [[ "${INSTALL_BASE_TOOLS}" == "true" ]]; then
section "INSTALLING BASE DEVOPS AND SERVER TOOLS"
BASE_PACKAGES=(
apt-transport-https
ca-certificates
curl
wget
gnupg
lsb-release
software-properties-common
git
vim
nano
htop
btop
tree
tmux
jq
yq
unzip
zip
ncdu
build-essential
make
gcc
g++
python3
python3-pip
python3-venv
python3-dev
openssh-server
net-tools
dnsutils
iputils-ping
traceroute
mtr-tiny
nmap
tcpdump
lsof
socat
ufw
fail2ban
unattended-upgrades
update-notifier-common
landscape-common
chrony
smartmontools
lm-sensors
acl
rsync
bash-completion
avahi-daemon
snapd
ansible
plocate
samba
)
if [[ "${INSTALL_COCKPIT}" == "true" ]]; then
BASE_PACKAGES+=(cockpit cockpit-pcp)
fi
for pkg in "${BASE_PACKAGES[@]}"; do
install_if_available "${pkg}"
done
run systemctl enable --now ssh || true
run systemctl enable --now chrony || true
run systemctl enable --now fail2ban || true
run systemctl enable --now avahi-daemon || true
run systemctl enable --now snapd || true
if [[ "${INSTALL_COCKPIT}" == "true" ]]; then
run systemctl enable --now cockpit.socket || true
fi
else
warn "INSTALL_BASE_TOOLS=false, skipping base tools"
fi
# -------------------------
# Sensors
# -------------------------
section "SETTING UP HARDWARE SENSOR DETECTION"
if command -v sensors-detect >/dev/null 2>&1; then
log "Running sensors-detect automatically"
yes | sensors-detect --auto || true
else
warn "sensors-detect not available"
fi
# -------------------------
# SSH hardening
# -------------------------
section "APPLYING BASIC SSH HARDENING"
mkdir -p /etc/ssh/sshd_config.d
cat >/etc/ssh/sshd_config.d/99-lw-homelab.conf <<'SSHCONF'
PermitRootLogin no
X11Forwarding no
MaxAuthTries 4
ClientAliveInterval 300
ClientAliveCountMax 2
SSHCONF
log "Created /etc/ssh/sshd_config.d/99-lw-homelab.conf"
run systemctl reload ssh || run systemctl reload sshd || true
# -------------------------
# Fail2Ban
# -------------------------
section "CONFIGURING FAIL2BAN"
mkdir -p /etc/fail2ban/jail.d
cat >/etc/fail2ban/jail.d/sshd.local <<'F2B'
[sshd]
enabled = true
port = ssh
filter = sshd
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
F2B
log "Created /etc/fail2ban/jail.d/sshd.local"
run systemctl restart fail2ban || true
# -------------------------
# No sleep on lid close
# -------------------------
section "DISABLING SLEEP WHEN LAPTOP LID IS CLOSED"
mkdir -p /etc/systemd/logind.conf.d
cat >/etc/systemd/logind.conf.d/99-lw-homelab-lid.conf <<'LIDCONF'
[Login]
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
IdleAction=ignore
LIDCONF
log "Created /etc/systemd/logind.conf.d/99-lw-homelab-lid.conf"
run systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target || true
log "Lid/sleep changes will be fully active after reboot."
# -------------------------
# Kernel modules/sysctl for Kubernetes
# -------------------------
section "CONFIGURING KUBERNETES-FRIENDLY KERNEL SETTINGS"
cat >/etc/modules-load.d/k8s.conf <<'MODULES'
overlay
br_netfilter
MODULES
log "Created /etc/modules-load.d/k8s.conf"
modprobe overlay || true
modprobe br_netfilter || true
cat >/etc/sysctl.d/99-lw-homelab-k8s.conf <<'SYSCTL'
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
vm.swappiness = 10
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 1024
SYSCTL
log "Created /etc/sysctl.d/99-lw-homelab-k8s.conf"
run sysctl --system || true
# -------------------------
# Static IP netplan
# -------------------------
section "CONFIGURING STATIC IP WITH NETPLAN"
DEFAULT_IFACE="$(ip route show default | awk '{print $5; exit}')"
if [[ -z "${DEFAULT_IFACE}" ]]; then
err "Could not detect default network interface. Check: ip route"
exit 1
fi
log "Detected default interface: ${DEFAULT_IFACE}"
log "Static IP to apply: ${STATIC_IP}"
log "Gateway: ${GATEWAY_IP}"
log "DNS: ${DNS_1}, ${DNS_2}"
mkdir -p /root/netplan-backup
cp -a /etc/netplan/*.yaml /root/netplan-backup/ 2>/dev/null || true
log "Backed up existing netplan YAML files to /root/netplan-backup"
NETPLAN_FILE="/etc/netplan/01-lw-homelab-static.yaml"
if [[ "${DEFAULT_IFACE}" == wl* ]]; then
warn "Wi-Fi interface detected: ${DEFAULT_IFACE}"
if [[ -z "${WIFI_SSID}" || -z "${WIFI_PASSWORD}" ]]; then
err "Wi-Fi detected but WIFI_SSID/WIFI_PASSWORD are missing."
err "Example: sudo env WIFI_SSID='Bedroom' WIFI_PASSWORD='password' bash bootstrap-homelab-full.sh"
exit 1
fi
cat >"${NETPLAN_FILE}" <<EOF_NETPLAN_WIFI
network:
version: 2
renderer: networkd
wifis:
${DEFAULT_IFACE}:
dhcp4: false
addresses:
- ${STATIC_IP}
routes:
- to: default
via: ${GATEWAY_IP}
nameservers:
addresses:
- ${DNS_1}
- ${DNS_2}
access-points:
"${WIFI_SSID}":
password: "${WIFI_PASSWORD}"
EOF_NETPLAN_WIFI
else
cat >"${NETPLAN_FILE}" <<EOF_NETPLAN_ETH
network:
version: 2
renderer: networkd
ethernets:
${DEFAULT_IFACE}:
dhcp4: false
addresses:
- ${STATIC_IP}
routes:
- to: default
via: ${GATEWAY_IP}
nameservers:
addresses:
- ${DNS_1}
- ${DNS_2}
EOF_NETPLAN_ETH
fi
chmod 600 "${NETPLAN_FILE}"
log "Created ${NETPLAN_FILE}"
if [[ "${DISABLE_CLOUD_INIT_NETWORK}" == "true" ]]; then
mkdir -p /root/netplan-disabled
if [[ -f /etc/netplan/50-cloud-init.yaml ]]; then
cp /etc/netplan/50-cloud-init.yaml /root/netplan-disabled/50-cloud-init.yaml.backup-$(date +%Y%m%d-%H%M%S) || true
mv /etc/netplan/50-cloud-init.yaml /etc/netplan/50-cloud-init.yaml.disabled || true
log "Disabled /etc/netplan/50-cloud-init.yaml to avoid duplicate DHCP IP"
fi
mkdir -p /etc/cloud/cloud.cfg.d
cat >/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg <<'CLOUDINIT'
network: {config: disabled}
CLOUDINIT
log "Created /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg"
fi
log "Netplan file preview with password hidden:"
sed 's/password:.*/password: "********"/g' "${NETPLAN_FILE}"
run netplan generate
warn "Applying netplan now. SSH may disconnect if you are connected remotely."
run netplan apply
sleep 3
log "Network after netplan apply:"
ip -br addr || true
ip route || true
# -------------------------
# Firewall
# -------------------------
section "CONFIGURING FIREWALL"
if [[ "${ENABLE_UFW}" == "true" ]]; then
run ufw default deny incoming
run ufw default allow outgoing
run ufw allow OpenSSH
run ufw allow 9090/tcp comment 'Cockpit web console'
run ufw allow 80/tcp comment 'HTTP'
run ufw allow 443/tcp comment 'HTTPS'
run ufw allow 3000/tcp comment 'Dev app / Grafana style port'
run ufw allow 5000/tcp comment 'Flask app port'
run ufw allow 8000/tcp comment 'Dev app port'
run ufw allow 8080/tcp comment 'Dev app port'
run ufw allow "${PORTAINER_PORT}/tcp" comment 'Portainer'
run ufw allow "${HOMEPAGE_PORT}/tcp" comment 'Homepage dashboard'
run ufw allow "${JELLYFIN_PORT}/tcp" comment 'Jellyfin media server'
run ufw allow "${KAVITA_PORT}/tcp" comment 'Kavita reading server'
run ufw allow "${UPTIME_KUMA_PORT}/tcp" comment 'Uptime Kuma monitoring'
run ufw allow Samba || true
run ufw allow 6443/tcp comment 'K3s Kubernetes API'
run ufw allow 10250/tcp comment 'Kubelet metrics'
run ufw allow 8472/udp comment 'Flannel VXLAN for future multi-node K3s'
ufw allow from 10.42.0.0/16 comment 'K3s pod network' || true
ufw allow from 10.43.0.0/16 comment 'K3s service network' || true
run ufw --force enable
run ufw status verbose
else
warn "ENABLE_UFW=false, skipping firewall setup"
fi
# -------------------------
# Docker install
# -------------------------
if [[ "${INSTALL_DOCKER}" == "true" ]]; then
section "INSTALLING DOCKER ENGINE AND DOCKER COMPOSE"
for oldpkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
apt-get remove -y "$oldpkg" >/dev/null 2>&1 || true
done
run install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
cat >/etc/apt/sources.list.d/docker.sources <<EOF_DOCKER
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: ${UBUNTU_CODENAME_DETECTED}
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF_DOCKER
log "Created /etc/apt/sources.list.d/docker.sources"
run apt-get update -y
run env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
run systemctl enable --now docker
mkdir -p /etc/docker
cat >/etc/docker/daemon.json <<'DOCKERDAEMON'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "20m",
"max-file": "3"
},
"exec-opts": ["native.cgroupdriver=systemd"]
}
DOCKERDAEMON
log "Created /etc/docker/daemon.json"
run systemctl restart docker
if id "${ADMIN_USER}" >/dev/null 2>&1; then
run usermod -aG docker "${ADMIN_USER}"
log "Added ${ADMIN_USER} to docker group. This applies after logout/reboot."
fi
run docker --version
run docker compose version
else
warn "INSTALL_DOCKER=false, skipping Docker"
fi
# -------------------------
# Helm/k9s using snap
# -------------------------
section "INSTALLING KUBERNETES CLI HELPERS"
if command -v snap >/dev/null 2>&1; then
if [[ "${INSTALL_HELM}" == "true" ]]; then
snap install helm --classic || warn "Helm snap install failed or already installed"
fi
if [[ "${INSTALL_K9S}" == "true" ]]; then
snap install k9s || warn "K9s snap install failed or already installed"
fi
else
warn "snap not available, skipping helm/k9s"
fi
# -------------------------
# K3s install
# -------------------------
if [[ "${INSTALL_K3S}" == "true" ]]; then
section "INSTALLING K3S KUBERNETES"
log "K3s channel: ${K3S_CHANNEL}"
log "K3s args: ${K3S_EXTRA_ARGS}"
curl -sfL https://get.k3s.io -o /tmp/install-k3s.sh
chmod +x /tmp/install-k3s.sh
INSTALL_K3S_CHANNEL="${K3S_CHANNEL}" INSTALL_K3S_EXEC="server ${K3S_EXTRA_ARGS}" sh /tmp/install-k3s.sh
run systemctl enable --now k3s
log "Waiting for K3s node to become ready"
sleep 20
mkdir -p "${ADMIN_HOME}/.kube"
cp /etc/rancher/k3s/k3s.yaml "${ADMIN_HOME}/.kube/config"
sed -i "s/127.0.0.1/${NODE_IP}/g" "${ADMIN_HOME}/.kube/config"
chown -R "${ADMIN_USER}:${ADMIN_USER}" "${ADMIN_HOME}/.kube" || true
chmod 600 "${ADMIN_HOME}/.kube/config"
log "Created kubeconfig for ${ADMIN_USER}: ${ADMIN_HOME}/.kube/config"
if [[ -f "${ADMIN_HOME}/.bashrc" ]]; then
grep -q 'KUBECONFIG=$HOME/.kube/config' "${ADMIN_HOME}/.bashrc" || echo 'export KUBECONFIG=$HOME/.kube/config' >>"${ADMIN_HOME}/.bashrc"
grep -q 'alias k=kubectl' "${ADMIN_HOME}/.bashrc" || echo 'alias k=kubectl' >>"${ADMIN_HOME}/.bashrc"
fi
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
run kubectl get nodes -o wide || true
run kubectl get pods -A || true
else
warn "INSTALL_K3S=false, skipping K3s"
fi
# -------------------------
# Homelab folder structure
# -------------------------
section "CREATING HOMELAB FOLDER STRUCTURE"
HOMELAB_DIRS=(
/opt/homelab
/opt/homelab/apps
/opt/homelab/compose
/opt/homelab/k8s
/opt/homelab/k8s/manifests
/opt/homelab/k8s/helm-values
/opt/homelab/scripts
/opt/homelab/backups
/opt/homelab/logs
/opt/homelab/configs
/srv/docker-data
/srv/k3s-storage
/srv/backups
/srv/shared
"${STORAGE_MOUNT}"
)
for dir in "${HOMELAB_DIRS[@]}"; do
mkdir -p "$dir"
log "Created/verified folder: $dir"
done
chown -R "${ADMIN_USER}:${ADMIN_USER}" /opt/homelab /srv/docker-data /srv/k3s-storage /srv/backups /srv/shared "${STORAGE_MOUNT}" || true
log "Ownership applied to homelab folders for user: ${ADMIN_USER}"
# -------------------------
# Optional HDD wipe/format/mount
# -------------------------
section "STORAGE SETUP"
if [[ "${CONFIGURE_STORAGE}" == "true" ]]; then
warn "CONFIGURE_STORAGE=true"
if [[ "${WIPE_STORAGE}" != "true" ]]; then
warn "WIPE_STORAGE is not true, so the script will not wipe ${STORAGE_DISK}."
warn "To wipe/format storage, run with CONFIGURE_STORAGE=true WIPE_STORAGE=true STORAGE_DISK=/dev/sdb"
else
warn "DANGEROUS MODE: WIPE_STORAGE=true. This will erase ${STORAGE_DISK}."
ROOT_SRC="$(findmnt -n -o SOURCE / | sed 's/[0-9]*$//' | sed 's/p$//')"
if [[ "${STORAGE_DISK}" == "${ROOT_SRC}" || "${STORAGE_DISK}" == "/dev/sda" ]]; then
err "Refusing to wipe ${STORAGE_DISK}. It looks like the OS/root disk."
exit 1
fi
if [[ ! -b "${STORAGE_DISK}" ]]; then
err "Storage disk ${STORAGE_DISK} not found. Run: lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINTS,MODEL"
exit 1
fi
log "Current disk layout before wipe:"
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINTS,MODEL "${STORAGE_DISK}" || true
for part in ${STORAGE_DISK}?*; do
[[ -e "$part" ]] && umount "$part" 2>/dev/null || true
done
run wipefs -a "${STORAGE_DISK}"
run parted "${STORAGE_DISK}" --script mklabel gpt
run parted "${STORAGE_DISK}" --script mkpart primary ext4 0% 100%
run partprobe "${STORAGE_DISK}"
sleep 3
if [[ ! -b "${STORAGE_PARTITION}" ]]; then
STORAGE_PARTITION="$(lsblk -ln -o NAME "${STORAGE_DISK}" | awk 'NR==2 {print "/dev/"$1}')"
fi
log "Formatting ${STORAGE_PARTITION} as ext4 label ${STORAGE_LABEL}"
run mkfs.ext4 -F -L "${STORAGE_LABEL}" "${STORAGE_PARTITION}"
run tune2fs -m "${STORAGE_RESERVED_PERCENT}" "${STORAGE_PARTITION}"
mkdir -p "${STORAGE_MOUNT}"
STORAGE_UUID="$(blkid -s UUID -o value "${STORAGE_PARTITION}")"
cp /etc/fstab /etc/fstab.backup-before-storage-$(date +%Y%m%d-%H%M%S)
grep -v " ${STORAGE_MOUNT} " /etc/fstab | grep -v "${STORAGE_UUID}" > /tmp/fstab.lw || true
echo "UUID=${STORAGE_UUID} ${STORAGE_MOUNT} ext4 defaults,nofail,x-systemd.device-timeout=10 0 2" >> /tmp/fstab.lw
cp /tmp/fstab.lw /etc/fstab
run systemctl daemon-reload
run mount -a
chown -R "${ADMIN_USER}:${ADMIN_USER}" "${STORAGE_MOUNT}" || true
fi
else
log "CONFIGURE_STORAGE=false, not wiping or formatting any disk. Using/creating ${STORAGE_MOUNT}."
fi
if [[ "${CREATE_STORAGE_FOLDERS}" == "true" ]]; then
section "CREATING STORAGE FOLDERS"
mkdir -p "${STORAGE_MOUNT}/"{Backups,Books,Downloads,ISO,Movies,Projects,Shared,Photos}
mkdir -p "${STORAGE_MOUNT}/Books/"{Ebooks,Comics,PDFs}
mkdir -p "${STORAGE_MOUNT}/appdata/"{homepage,jellyfin,kavita,uptime-kuma}
mkdir -p "${STORAGE_MOUNT}/appdata/jellyfin/"{config,cache}
chown -R "${ADMIN_USER}:${ADMIN_USER}" "${STORAGE_MOUNT}" || true
tree -L 3 "${STORAGE_MOUNT}" || true
fi
# -------------------------
# Samba share
# -------------------------
if [[ "${INSTALL_SAMBA}" == "true" ]]; then
section "CONFIGURING SAMBA WINDOWS SHARE"
install_if_available samba
run systemctl enable --now smbd || true
cp /etc/samba/smb.conf /etc/samba/smb.conf.backup-before-lw-share-$(date +%Y%m%d-%H%M%S) || true
# Remove old generated share block if present.
sed -i '/# LW_HOMELAB_SHARE_START/,/# LW_HOMELAB_SHARE_END/d' /etc/samba/smb.conf
cat >>/etc/samba/smb.conf <<EOF_SAMBA
# LW_HOMELAB_SHARE_START
[${SAMBA_SHARE_NAME}]
path = ${STORAGE_MOUNT}
browseable = yes
read only = no
writable = yes
valid users = ${ADMIN_USER}
force user = ${ADMIN_USER}
force group = ${ADMIN_USER}
create mask = 0664
directory mask = 0775
# LW_HOMELAB_SHARE_END
EOF_SAMBA
if [[ -n "${SAMBA_PASSWORD}" ]]; then
log "Setting Samba password for ${ADMIN_USER} from SAMBA_PASSWORD environment variable"
printf '%s\n%s\n' "${SAMBA_PASSWORD}" "${SAMBA_PASSWORD}" | smbpasswd -s -a "${ADMIN_USER}"
smbpasswd -e "${ADMIN_USER}"
else
warn "SAMBA_PASSWORD not provided. Share is configured, but you may need to run: sudo smbpasswd -a ${ADMIN_USER}"
fi
run testparm -s || true
run systemctl restart smbd
run systemctl is-active smbd || true
fi
# -------------------------
# Portainer
# -------------------------
if [[ "${INSTALL_PORTAINER}" == "true" ]]; then
section "INSTALLING PORTAINER"
run docker volume create portainer_data
docker rm -f portainer >/dev/null 2>&1 || true
run docker run -d \
--name portainer \
--restart=always \
-p "${PORTAINER_PORT}:9443" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v portainer_data:/data \
portainer/portainer-ce:latest
fi
# -------------------------
# Homepage/Jellyfin/Kavita/Uptime Kuma Docker Compose stack
# -------------------------
if [[ "${INSTALL_MEDIA_STACK}" == "true" ]]; then
section "CREATING MEDIA AND PERSONAL CLOUD DOCKER STACK"
mkdir -p "${MEDIA_STACK_DIR}"
chown -R "${ADMIN_USER}:${ADMIN_USER}" "${COMPOSE_ROOT}" || true
cat >"${MEDIA_STACK_DIR}/docker-compose.yml" <<EOF_COMPOSE
services:
homepage:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage
restart: unless-stopped
ports:
- "${HOMEPAGE_PORT}:3000"
environment:
HOMEPAGE_ALLOWED_HOSTS: "${NODE_IP}:${HOMEPAGE_PORT},${HOSTNAME}:${HOMEPAGE_PORT},localhost:${HOMEPAGE_PORT}"
PUID: "${ADMIN_UID}"
PGID: "${ADMIN_GID}"
volumes:
- ${STORAGE_MOUNT}/appdata/homepage:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
ports:
- "${JELLYFIN_PORT}:8096"
volumes:
- ${STORAGE_MOUNT}/appdata/jellyfin/config:/config
- ${STORAGE_MOUNT}/appdata/jellyfin/cache:/cache
- ${STORAGE_MOUNT}/Movies:/media/movies
environment:
TZ: "${TIMEZONE}"
kavita:
image: jvmilazz0/kavita:latest
container_name: kavita
restart: unless-stopped
ports:
- "${KAVITA_PORT}:5000"
volumes:
- ${STORAGE_MOUNT}/Books/Ebooks:/books
- ${STORAGE_MOUNT}/Books/Comics:/comics
- ${STORAGE_MOUNT}/Books/PDFs:/pdfs
- ${STORAGE_MOUNT}/appdata/kavita:/kavita/config
environment:
TZ: "${TIMEZONE}"
EOF_COMPOSE
if [[ "${INSTALL_UPTIME_KUMA}" == "true" ]]; then
cat >>"${MEDIA_STACK_DIR}/docker-compose.yml" <<EOF_COMPOSE_UPTIME
uptime-kuma:
image: louislam/uptime-kuma:latest
container_name: uptime-kuma
restart: unless-stopped
ports:
- "${UPTIME_KUMA_PORT}:3001"
volumes:
- ${STORAGE_MOUNT}/appdata/uptime-kuma:/app/data
EOF_COMPOSE_UPTIME
fi
log "Created ${MEDIA_STACK_DIR}/docker-compose.yml"
section "CREATING HOMEPAGE DASHBOARD CONFIG"
mkdir -p "${STORAGE_MOUNT}/appdata/homepage"
cat >"${STORAGE_MOUNT}/appdata/homepage/settings.yaml" <<EOF_HOME_SETTINGS
title: Mr Walia Homelab
theme: dark
color: slate
headerStyle: boxed
statusStyle: dot
layout:
Media:
style: row
columns: 2
Reading:
style: row
columns: 2
Server:
style: row
columns: 3
EOF_HOME_SETTINGS
cat >"${STORAGE_MOUNT}/appdata/homepage/services.yaml" <<EOF_HOME_SERVICES
- Media:
- WaliaFlix Jellyfin:
href: http://${NODE_IP}:${JELLYFIN_PORT}
description: Personal Netflix for movies and shows
icon: jellyfin.png
- Reading:
- Kavita:
href: http://${NODE_IP}:${KAVITA_PORT}
description: Personal book, PDF, comic and manga reader
icon: kavita.png
- Server:
- Portainer:
href: https://${NODE_IP}:${PORTAINER_PORT}
description: Docker control panel
icon: portainer.png
- Cockpit:
href: https://${NODE_IP}:9090
description: Ubuntu server web console
icon: cockpit.png
- Storage:
href: file://${NODE_IP}/${SAMBA_SHARE_NAME}
description: HDD storage and Samba share
icon: mdi-harddisk
EOF_HOME_SERVICES
if [[ "${INSTALL_UPTIME_KUMA}" == "true" ]]; then
cat >>"${STORAGE_MOUNT}/appdata/homepage/services.yaml" <<EOF_HOME_UPTIME
- Uptime Kuma:
href: http://${NODE_IP}:${UPTIME_KUMA_PORT}
description: Service uptime monitoring
icon: uptime-kuma.png
EOF_HOME_UPTIME
fi
cat >"${STORAGE_MOUNT}/appdata/homepage/bookmarks.yaml" <<EOF_HOME_BOOKMARKS
- DevOps:
- GitHub:
- abbr: GH
href: https://github.com/lakshaywalia666
- Docker Hub:
- abbr: DH
href: https://hub.docker.com/u/lakshaywalia666
- Homelab:
- Storage Share:
- abbr: HDD
href: file://${NODE_IP}/${SAMBA_SHARE_NAME}
EOF_HOME_BOOKMARKS
cat >"${STORAGE_MOUNT}/appdata/homepage/widgets.yaml" <<'EOF_HOME_WIDGETS'
- resources:
cpu: true
memory: true
disk: /
uptime: true
- search:
provider: duckduckgo
target: _blank
EOF_HOME_WIDGETS
touch "${STORAGE_MOUNT}/appdata/homepage/custom.css" "${STORAGE_MOUNT}/appdata/homepage/custom.js"
chown -R "${ADMIN_USER}:${ADMIN_USER}" "${STORAGE_MOUNT}/appdata"
section "STARTING MEDIA STACK"
cd "${MEDIA_STACK_DIR}"
run docker compose pull
run docker compose up -d
run docker compose ps
fi
# -------------------------
# Helper commands
# -------------------------
section "CREATING HELPER COMMANDS"
cat >/usr/local/bin/homelab-info <<'HOMELABINFO'
#!/usr/bin/env bash
MAIN_IP=$(hostname -I | awk '{print $1}')
echo "=============================="
echo " LW HOMELAB INFO"
echo "=============================="
echo "Date: $(date)"
echo "Hostname: $(hostname)"
echo
echo "IP addresses:"
ip -br addr || true
echo
echo "Default route:"
ip route | grep default || true
echo
echo "CPU:"
lscpu | grep -E 'Model name|CPU\(s\)|Thread|Core|Socket' || true
echo
echo "Memory:"
free -h || true
echo
echo "Disk:"
df -h / /srv/storage 2>/dev/null || df -h /
echo
echo "Temperatures:"
sensors 2>/dev/null | head -30 || echo "sensors not available"
echo
echo "Services:"
for svc in ssh cockpit.socket docker k3s fail2ban chrony smbd; do
printf '%-18s' "$svc"
systemctl is-active "$svc" 2>/dev/null || true
done
echo
echo "Docker containers:"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || true
echo
echo "Kubernetes:"
kubectl get nodes -o wide 2>/dev/null || echo "kubectl/K3s not ready"
kubectl get pods -A 2>/dev/null || true
echo
echo "Useful URLs:"
echo "Homepage: http://${MAIN_IP}:3001"
echo "Jellyfin: http://${MAIN_IP}:8096"
echo "Kavita: http://${MAIN_IP}:5001"
echo "Portainer: https://${MAIN_IP}:9443"
echo "Cockpit: https://${MAIN_IP}:9090"
echo "Samba: \\\\${MAIN_IP}\\HomelabStorage"
echo
echo "Bootstrap logs: /var/log/lw-homelab-bootstrap/latest.log"
HOMELABINFO
chmod +x /usr/local/bin/homelab-info
log "Created /usr/local/bin/homelab-info"
cat >/usr/local/bin/update-homelab <<'UPDATEHOMELAB'
#!/usr/bin/env bash
set -Eeuo pipefail
LOG_DIR="/var/log/lw-homelab-bootstrap"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/update-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$LOG_FILE") 2>&1
echo "Starting update at $(date)"
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y
sudo apt-get autoremove -y
sudo apt-get autoclean -y
echo
if command -v docker >/dev/null 2>&1; then
echo "Docker disk usage:"
docker system df || true
fi
echo
if command -v kubectl >/dev/null 2>&1; then
echo "K3s node status:"
kubectl get nodes -o wide || true
fi
echo "Update complete at $(date)"
echo "Log saved at: $LOG_FILE"
UPDATEHOMELAB
chmod +x /usr/local/bin/update-homelab
log "Created /usr/local/bin/update-homelab"
cat >/usr/local/bin/homelab-logs <<'HOMELABLOGS'
#!/usr/bin/env bash
LOG_FILE="/var/log/lw-homelab-bootstrap/latest.log"
if [[ -f "$LOG_FILE" ]]; then
less "$LOG_FILE"
else
echo "No bootstrap log found at $LOG_FILE"
fi
HOMELABLOGS
chmod +x /usr/local/bin/homelab-logs
log "Created /usr/local/bin/homelab-logs"
cat >/usr/local/bin/media-stack <<EOF_MEDIASTACK
#!/usr/bin/env bash
cd "${MEDIA_STACK_DIR}"
sudo docker compose "\$@"
EOF_MEDIASTACK
chmod +x /usr/local/bin/media-stack
log "Created /usr/local/bin/media-stack"
cat >/usr/local/bin/storage-info <<EOF_STORAGEINFO
#!/usr/bin/env bash
echo "Storage mount: ${STORAGE_MOUNT}"
df -h "${STORAGE_MOUNT}" || true
echo
findmnt "${STORAGE_MOUNT}" || true
echo
ls -lah "${STORAGE_MOUNT}" || true
EOF_STORAGEINFO
chmod +x /usr/local/bin/storage-info
log "Created /usr/local/bin/storage-info"
# -------------------------
# Aliases
# -------------------------
section "CREATING USEFUL SHELL ALIASES"
cat >/etc/profile.d/lw-homelab-aliases.sh <<'ALIASES'
alias hlinfo='homelab-info'
alias hllogs='homelab-logs'
alias storageinfo='storage-info'
alias media='media-stack'
alias ll='ls -lah'
alias ports='sudo ss -tulpn'
alias dps='docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"'
alias dcu='docker compose up -d'
alias dcd='docker compose down'
alias k='kubectl'
alias kgp='kubectl get pods -A -o wide'
alias kgs='kubectl get svc -A -o wide'
alias kgn='kubectl get nodes -o wide'
export KUBECONFIG="$HOME/.kube/config"
ALIASES
chmod 644 /etc/profile.d/lw-homelab-aliases.sh
log "Created /etc/profile.d/lw-homelab-aliases.sh"
# Home symlink to storage
if [[ -d "${ADMIN_HOME}" ]]; then
ln -sfn "${STORAGE_MOUNT}" "${ADMIN_HOME}/storage"
chown -h "${ADMIN_USER}:${ADMIN_USER}" "${ADMIN_HOME}/storage" || true
log "Created ${ADMIN_HOME}/storage symlink"
fi
# -------------------------
# MOTD welcome message
# -------------------------
section "CREATING LOGIN WELCOME MESSAGE FOR MR WALIA"
cat >/etc/update-motd.d/00-mr-walia-welcome <<'MOTD'
#!/usr/bin/env bash
MAIN_IP=$(hostname -I | awk '{print $1}')
CPU_MODEL=$(lscpu 2>/dev/null | sed -n 's/Model name:[[:space:]]*//p' | head -1 | xargs)
CPU_CORES=$(nproc 2>/dev/null || echo unknown)
RAM_TOTAL=$(free -h 2>/dev/null | awk '/Mem:/ {print $2}')
RAM_USED=$(free -h 2>/dev/null | awk '/Mem:/ {print $3}')
DISK_ROOT=$(df -h / 2>/dev/null | awk 'NR==2 {print $3 " used / " $2 " total (" $5 ")"}')
DISK_STORAGE=$(df -h /srv/storage 2>/dev/null | awk 'NR==2 {print $3 " used / " $2 " total (" $5 ")"}')
TEMP=$(sensors 2>/dev/null | awk '/Package id 0|Tctl|temp1/ {print $0; exit}')
DOCKER_STATE=$(systemctl is-active docker 2>/dev/null || echo "not installed")
K3S_STATE=$(systemctl is-active k3s 2>/dev/null || echo "not installed")
COCKPIT_STATE=$(systemctl is-active cockpit.socket 2>/dev/null || echo "not active")
SMB_STATE=$(systemctl is-active smbd 2>/dev/null || echo "not active")
LAST_LOG="/var/log/lw-homelab-bootstrap/latest.log"
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Hello Mr Walia 👋 ║"
echo "║ Welcome to your DevOps Homelab ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo " Date/Time : $(date '+%A, %d %B %Y - %I:%M:%S %p %Z')"
echo " Hostname : $(hostname)"
echo " Main IP : ${MAIN_IP:-not detected}"
echo " Uptime : $(uptime -p 2>/dev/null)"
echo " CPU : ${CPU_MODEL:-unknown}"
echo " CPU Cores : ${CPU_CORES}"
echo " RAM : ${RAM_USED:-?} used / ${RAM_TOTAL:-?} total"
echo " Root Disk : ${DISK_ROOT:-unknown}"
echo " Storage Disk : ${DISK_STORAGE:-not mounted}"
if [[ -n "${TEMP}" ]]; then echo " Temperature : ${TEMP}"; fi
echo " Docker : ${DOCKER_STATE}"
echo " K3s Kubernetes : ${K3S_STATE}"
echo " Cockpit : ${COCKPIT_STATE}"
echo " Samba : ${SMB_STATE}"
echo " Homepage : http://${MAIN_IP:-SERVER-IP}:3001"
echo " Jellyfin : http://${MAIN_IP:-SERVER-IP}:8096"
echo " Kavita : http://${MAIN_IP:-SERVER-IP}:5001"
echo " Portainer : https://${MAIN_IP:-SERVER-IP}:9443"
echo " Cockpit URL : https://${MAIN_IP:-SERVER-IP}:9090"
echo " Bootstrap Log : ${LAST_LOG}"
echo " Quick Commands : hlinfo | hllogs | storageinfo | media ps | kgn | kgp | dps | ports"
echo ""
MOTD
chmod +x /etc/update-motd.d/00-mr-walia-welcome
log "Created /etc/update-motd.d/00-mr-walia-welcome"
if [[ -f /etc/update-motd.d/10-help-text ]]; then chmod -x /etc/update-motd.d/10-help-text || true; fi
if [[ -f /etc/update-motd.d/50-motd-news ]]; then chmod -x /etc/update-motd.d/50-motd-news || true; fi
# -------------------------
# Automatic security updates
# -------------------------
section "ENABLING AUTOMATIC SECURITY UPDATES"
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'AUTOUPGRADES'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
AUTOUPGRADES
log "Created /etc/apt/apt.conf.d/20auto-upgrades"
run systemctl enable --now unattended-upgrades || true
# -------------------------
# Final checks
# -------------------------
section "FINAL SYSTEM STATUS"
print_system_report
section "SERVICE STATUS"
for svc in ssh cockpit.socket docker k3s fail2ban chrony smbd; do
echo
echo "--- ${svc} ---"
systemctl status "$svc" --no-pager -l || true
done
section "FINAL DEVOPS TOOL VERSIONS"
git --version || true
python3 --version || true
ansible --version | head -3 || true
docker --version || true
docker compose version || true
kubectl version --client=true 2>/dev/null || true
helm version 2>/dev/null || true
k9s version 2>/dev/null || true
smbd --version || true
section "FINAL KUBERNETES CHECK"
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o wide 2>/dev/null || true
kubectl get pods -A 2>/dev/null || true
kubectl get svc -A 2>/dev/null || true
section "FINAL DOCKER APP CHECK"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" || true
section "FINAL STORAGE CHECK"
findmnt "${STORAGE_MOUNT}" || true
df -h "${STORAGE_MOUNT}" || true
ls -lah "${STORAGE_MOUNT}" || true
section "CLEANUP"
run apt-get autoremove -y
run apt-get autoclean -y
section "BOOTSTRAP COMPLETE"
echo "Hello Mr Walia, your massive homelab setup is complete."
echo "Static IP : ${NODE_IP}"
echo "SSH : ssh ${ADMIN_USER}@${NODE_IP}"
echo "Homepage UI : http://${NODE_IP}:${HOMEPAGE_PORT}"
echo "Jellyfin : http://${NODE_IP}:${JELLYFIN_PORT}"
echo "Kavita : http://${NODE_IP}:${KAVITA_PORT}"
echo "Portainer : https://${NODE_IP}:${PORTAINER_PORT}"
echo "Cockpit : https://${NODE_IP}:9090"
echo "Samba Storage : \\\\${NODE_IP}\\${SAMBA_SHARE_NAME}"
echo "Storage path : ${STORAGE_MOUNT}"
echo "K3s kubeconfig : ${ADMIN_HOME}/.kube/config"
echo "Compose path : ${MEDIA_STACK_DIR}"
echo "Main log file : ${LOG_FILE}"
echo "Latest log file : ${LOG_DIR}/latest.log"
echo "View logs later : homelab-logs"
echo "System summary : homelab-info"
echo
if [[ -f /var/run/reboot-required ]]; then
warn "Ubuntu says reboot is required."
fi
if [[ "${AUTO_REBOOT}" == "true" ]]; then
warn "Rebooting now. The full setup log is saved at: ${LOG_FILE}"
sleep 8
reboot
else
warn "AUTO_REBOOT=false, not rebooting. Reboot manually with: sudo reboot"
fi