SmartestHome/hosts/container-host/scripts/setup-container-host.sh

631 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Smart Home Container Host Setup
# Target: Debian 12 (Bookworm) or Raspberry Pi OS Lite (Bookworm-based)
#
# Sets up Docker + a compose stack for everything that lives on this single
# host per docs/project-plan.md Phase 1 and Phase 9 (the LLM/GPU host is a
# separate physical machine — see hosts/llm-host):
# - Home Assistant (Container install)
# - Mosquitto (MQTT broker)
# - Zigbee2MQTT (talks to your USB-attached CC2652P/Haozee coordinator)
# - Node-RED (automation glue, identity-correlation flow home)
# - Frigate (NVR + face recognition, for the peephole cam)
# - Grocy (self-hosted inventory / shopping list)
# - Mealie (optional meal planning)
# - Netdata (host + container monitoring)
# - Homepage (single dashboard landing page)
# - ntfy (self-hosted push notifications)
# - Portainer (Docker GUI)
# - restic scheduled backups (optional, off by default)
#
# Run as: sudo ./setup-container-host.sh
#
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
set -euo pipefail
# ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running
# ---------------------------------------------------------------------------
BASE_DIR="/opt/smart-home" # Where all container config/data will live
TIMEZONE="Europe/Vienna" # Adjust to your timezone
ENABLE_MEALIE="false" # Set to "true" to also deploy Mealie
ENABLE_INTEL_HWACCEL="false" # Set to "true" if this host has an Intel iGPU for Frigate
# --- Phase 1/9 add-ons — on by default, set to "false" to skip any of them ---
ENABLE_NODERED="true"
ENABLE_NETDATA="true"
ENABLE_HOMEPAGE="true"
ENABLE_NTFY="true"
ENABLE_PORTAINER="true"
# --- Scheduled backups (restic) — off by default until you pick a target ---
# Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY to a local path (e.g. an
# external/USB drive mount, or a NAS mount), or a remote target restic
# supports (s3:..., sftp:..., b2:..., rest:...). See https://restic.net
ENABLE_BACKUPS="false"
RESTIC_REPOSITORY="/mnt/backup/smart-home-restic"
BACKUP_SCHEDULE="03:30" # systemd OnCalendar time, daily at this local time
# --- Zigbee adapter: USB (CC2652P/CH340C, e.g. Haozee/Sonoff Dongle-P style) ---
# Run `ls -l /dev/serial/by-id/` AFTER plugging the adapter in, and paste the
# full path it shows here. This is more stable across reboots than /dev/ttyUSB0.
# Example: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
ZIGBEE_USB_DEVICE="/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0"
# ---------------------------------------------------------------------------
# Sanity checks
# ---------------------------------------------------------------------------
if [[ $EUID -ne 0 ]]; then
echo "Please run as root (sudo ./setup-container-host.sh)" >&2
exit 1
fi
if ! grep -qi "debian" /etc/os-release; then
echo "Warning: this script targets Debian/Raspberry Pi OS. Proceeding anyway..."
fi
if [[ ! -e "$ZIGBEE_USB_DEVICE" ]]; then
echo "Warning: $ZIGBEE_USB_DEVICE does not exist yet."
echo " Plug in your Zigbee adapter and run 'ls -l /dev/serial/by-id/' to find the correct path,"
echo " then edit ZIGBEE_USB_DEVICE at the top of this script before re-running."
echo " Continuing anyway — the zigbee2mqtt container just won't start correctly until this is fixed."
fi
if [[ "$ENABLE_BACKUPS" == "true" && "$RESTIC_REPOSITORY" == "/mnt/backup/smart-home-restic" ]]; then
echo "Warning: ENABLE_BACKUPS=true but RESTIC_REPOSITORY is still the placeholder path."
echo " Edit RESTIC_REPOSITORY at the top of this script to point at real backup storage."
fi
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
HOST_IP="${HOST_IP:-<host-ip>}"
echo "=== Smart Home Container Host Setup ==="
echo "Base directory: $BASE_DIR"
echo "Timezone: $TIMEZONE"
echo "Host IP (detected): $HOST_IP"
echo "Zigbee adapter (USB): ${ZIGBEE_USB_DEVICE}"
echo
# ---------------------------------------------------------------------------
# 1. System update + prerequisites
# ---------------------------------------------------------------------------
echo "--- Updating system and installing prerequisites ---"
apt-get update
apt-get upgrade -y
apt-get install -y \
ca-certificates \
curl \
gnupg \
lsb-release \
jq \
openssl
if [[ "$ENABLE_BACKUPS" == "true" ]]; then
apt-get install -y restic
fi
# ---------------------------------------------------------------------------
# 2. Install Docker Engine + Compose plugin (official Docker repo)
# ---------------------------------------------------------------------------
if ! command -v docker &> /dev/null; then
echo "--- Installing Docker Engine ---"
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
ARCH="$(dpkg --print-architecture)"
CODENAME="$(. /etc/os-release && echo "$VERSION_CODENAME")"
echo \
"deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Allow the invoking (non-root) user to run docker without sudo, if applicable
if [[ -n "${SUDO_USER:-}" ]]; then
usermod -aG docker "$SUDO_USER"
echo "Added $SUDO_USER to the docker group. Log out/in for it to take effect."
fi
else
echo "--- Docker already installed, skipping ---"
fi
# ---------------------------------------------------------------------------
# 3. Directory structure
# ---------------------------------------------------------------------------
echo "--- Creating directory structure under $BASE_DIR ---"
mkdir -p "$BASE_DIR"/{homeassistant,mosquitto/config,mosquitto/data,mosquitto/log,zigbee2mqtt/data,frigate/config,frigate/media,grocy/config,grocy/data}
if [[ "$ENABLE_MEALIE" == "true" ]]; then
mkdir -p "$BASE_DIR"/mealie/data
fi
if [[ "$ENABLE_NODERED" == "true" ]]; then
mkdir -p "$BASE_DIR"/nodered/data
fi
if [[ "$ENABLE_NETDATA" == "true" ]]; then
mkdir -p "$BASE_DIR"/netdata/{config,lib,cache}
fi
if [[ "$ENABLE_HOMEPAGE" == "true" ]]; then
mkdir -p "$BASE_DIR"/homepage/config
fi
if [[ "$ENABLE_NTFY" == "true" ]]; then
mkdir -p "$BASE_DIR"/ntfy/{data,config}
fi
if [[ "$ENABLE_PORTAINER" == "true" ]]; then
mkdir -p "$BASE_DIR"/portainer/data
fi
# ---------------------------------------------------------------------------
# 4. Mosquitto config
# ---------------------------------------------------------------------------
echo "--- Writing Mosquitto config ---"
cat > "$BASE_DIR/mosquitto/config/mosquitto.conf" <<'EOF'
# Basic internal-network broker config.
# This trusts anything on your Docker/LAN network. If Mosquitto will ever be
# reachable beyond your trusted LAN, add password_file auth before exposing it.
listener 1883
allow_anonymous true
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
EOF
# ---------------------------------------------------------------------------
# 5. Zigbee2MQTT config (pre-seeded for your USB CC2652P dongle)
# ---------------------------------------------------------------------------
echo "--- Writing Zigbee2MQTT config ---"
cat > "$BASE_DIR/zigbee2mqtt/data/configuration.yaml" <<EOF
homeassistant: true
permit_join: false
mqtt:
base_topic: zigbee2mqtt
server: 'mqtt://mosquitto:1883'
serial:
port: '/dev/ttyACM0'
adapter: zstack
advanced:
network_key: GENERATE
frontend:
port: 8080
EOF
echo " NOTE: adapter set to 'zstack' for your CC2652P (Haozee/Sonoff Dongle-P style) USB dongle."
echo " If Zigbee2MQTT fails to connect, it may need CC2652P coordinator firmware flashed first"
echo " (generic-branded gateways sometimes ship with non-coordinator firmware)."
# ---------------------------------------------------------------------------
# 6. Frigate config (placeholder — edit before first real use)
# ---------------------------------------------------------------------------
echo "--- Writing Frigate placeholder config ---"
cat > "$BASE_DIR/frigate/config/config.yml" <<'EOF'
mqtt:
host: mosquitto
port: 1883
# Face recognition (Frigate 0.16+)
face_recognition:
enabled: true
cameras:
peephole:
ffmpeg:
inputs:
- path: rtsp://USERNAME:PASSWORD@CAMERA_IP:554/STREAM_PATH
roles:
- detect
- record
detect:
width: 1280
height: 720
fps: 5
record:
enabled: true
# Uncomment and adjust if using Intel QuickSync/OpenVINO hardware acceleration:
# ffmpeg:
# hwaccel_args: preset-vaapi
# detectors:
# ov:
# type: openvino
# device: GPU
EOF
echo " NOTE: edit $BASE_DIR/frigate/config/config.yml with your real camera RTSP URL before starting Frigate."
# ---------------------------------------------------------------------------
# 7. Homepage dashboard config (single landing page over the whole stack)
# ---------------------------------------------------------------------------
if [[ "$ENABLE_HOMEPAGE" == "true" ]]; then
echo "--- Writing Homepage config ---"
cat > "$BASE_DIR/homepage/config/settings.yaml" <<'EOF'
title: Smart Home
theme: dark
color: slate
headerStyle: clean
EOF
cat > "$BASE_DIR/homepage/config/widgets.yaml" <<'EOF'
- resources:
cpu: true
memory: true
disk: /
EOF
cat > "$BASE_DIR/homepage/config/bookmarks.yaml" <<'EOF'
[]
EOF
cat > "$BASE_DIR/homepage/config/services.yaml" <<EOF
- Home:
- Home Assistant:
href: http://${HOST_IP}:8123
description: Home automation hub
- Node-RED:
href: http://${HOST_IP}:1880
description: Automation flows
- Devices:
- Zigbee2MQTT:
href: http://${HOST_IP}:8080
description: Zigbee network
- Frigate:
href: http://${HOST_IP}:5000
description: NVR / face recognition
- Household:
- Grocy:
href: http://${HOST_IP}:9283
description: Inventory & shopping list
- System:
- Portainer:
href: http://${HOST_IP}:9000
description: Container management
- Netdata:
href: http://${HOST_IP}:19999
description: Host/container monitoring
- ntfy:
href: http://${HOST_IP}:8090
description: Push notifications
EOF
fi
# ---------------------------------------------------------------------------
# 8. docker-compose.yml
# ---------------------------------------------------------------------------
echo "--- Writing docker-compose.yml ---"
FRIGATE_DEVICES=""
if [[ "$ENABLE_INTEL_HWACCEL" == "true" ]]; then
FRIGATE_DEVICES=" devices:
- /dev/dri:/dev/dri"
fi
MEALIE_BLOCK=""
if [[ "$ENABLE_MEALIE" == "true" ]]; then
MEALIE_BLOCK="
mealie:
image: ghcr.io/mealie-recipes/mealie:latest
container_name: mealie
restart: unless-stopped
ports:
- \"9925:9000\"
volumes:
- ${BASE_DIR}/mealie/data:/app/data
environment:
- TZ=${TIMEZONE}
- ALLOW_SIGNUP=false
"
fi
NODERED_BLOCK=""
if [[ "$ENABLE_NODERED" == "true" ]]; then
NODERED_BLOCK="
nodered:
image: nodered/node-red:latest
container_name: nodered
restart: unless-stopped
depends_on:
- mosquitto
ports:
- \"1880:1880\"
volumes:
- ${BASE_DIR}/nodered/data:/data
- /etc/localtime:/etc/localtime:ro
environment:
- TZ=${TIMEZONE}
"
fi
NETDATA_BLOCK=""
if [[ "$ENABLE_NETDATA" == "true" ]]; then
NETDATA_BLOCK="
netdata:
image: netdata/netdata:stable
container_name: netdata
restart: unless-stopped
hostname: smart-home-host
network_mode: host
pid: host
cap_add:
- SYS_PTRACE
- SYS_ADMIN
security_opt:
- apparmor:unconfined
volumes:
- ${BASE_DIR}/netdata/config:/etc/netdata
- ${BASE_DIR}/netdata/lib:/var/lib/netdata
- ${BASE_DIR}/netdata/cache:/var/cache/netdata
- /etc/passwd:/host/etc/passwd:ro
- /etc/group:/host/etc/group:ro
- /etc/os-release:/host/etc/os-release:ro
- /var/log:/host/var/log:ro
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
"
fi
HOMEPAGE_BLOCK=""
if [[ "$ENABLE_HOMEPAGE" == "true" ]]; then
HOMEPAGE_BLOCK="
homepage:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage
restart: unless-stopped
ports:
- \"3000:3000\"
volumes:
- ${BASE_DIR}/homepage/config:/app/config
environment:
- TZ=${TIMEZONE}
"
fi
NTFY_BLOCK=""
if [[ "$ENABLE_NTFY" == "true" ]]; then
NTFY_BLOCK="
ntfy:
image: binwiederhier/ntfy:latest
container_name: ntfy
restart: unless-stopped
command: serve
ports:
- \"8090:80\"
volumes:
- ${BASE_DIR}/ntfy/data:/var/lib/ntfy
- ${BASE_DIR}/ntfy/config:/etc/ntfy
environment:
- TZ=${TIMEZONE}
"
fi
PORTAINER_BLOCK=""
if [[ "$ENABLE_PORTAINER" == "true" ]]; then
PORTAINER_BLOCK="
portainer:
image: portainer/portainer-ce:lts
container_name: portainer
restart: unless-stopped
command: -H unix:///var/run/docker.sock
ports:
- \"9000:9000\"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${BASE_DIR}/portainer/data:/data
"
fi
cat > "$BASE_DIR/docker-compose.yml" <<EOF
name: smart-home
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant
restart: unless-stopped
privileged: true
network_mode: host
volumes:
- ${BASE_DIR}/homeassistant:/config
- /etc/localtime:/etc/localtime:ro
environment:
- TZ=${TIMEZONE}
mosquitto:
image: eclipse-mosquitto:2
container_name: mosquitto
restart: unless-stopped
ports:
- "1883:1883"
volumes:
- ${BASE_DIR}/mosquitto/config:/mosquitto/config
- ${BASE_DIR}/mosquitto/data:/mosquitto/data
- ${BASE_DIR}/mosquitto/log:/mosquitto/log
zigbee2mqtt:
image: koenkk/zigbee2mqtt:latest
container_name: zigbee2mqtt
restart: unless-stopped
depends_on:
- mosquitto
ports:
- "8080:8080"
volumes:
- ${BASE_DIR}/zigbee2mqtt/data:/app/data
- /etc/localtime:/etc/localtime:ro
devices:
- ${ZIGBEE_USB_DEVICE}:/dev/ttyACM0
environment:
- TZ=${TIMEZONE}
frigate:
image: ghcr.io/blakeblackshear/frigate:stable
container_name: frigate
restart: unless-stopped
privileged: true
shm_size: "256mb"
depends_on:
- mosquitto
ports:
- "5000:5000"
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
volumes:
- ${BASE_DIR}/frigate/config:/config
- ${BASE_DIR}/frigate/media:/media/frigate
- /etc/localtime:/etc/localtime:ro
${FRIGATE_DEVICES}
environment:
- TZ=${TIMEZONE}
grocy:
image: lscr.io/linuxserver/grocy:latest
container_name: grocy
restart: unless-stopped
ports:
- "9283:80"
volumes:
- ${BASE_DIR}/grocy/config:/config
environment:
- PUID=1000
- PGID=1000
- TZ=${TIMEZONE}
${MEALIE_BLOCK}${NODERED_BLOCK}${NETDATA_BLOCK}${HOMEPAGE_BLOCK}${NTFY_BLOCK}${PORTAINER_BLOCK}
EOF
# ---------------------------------------------------------------------------
# 9. Scheduled backups (restic) — optional
# ---------------------------------------------------------------------------
if [[ "$ENABLE_BACKUPS" == "true" ]]; then
echo "--- Setting up restic backups ---"
RESTIC_PASSWORD_FILE="$BASE_DIR/.restic-password"
if [[ ! -f "$RESTIC_PASSWORD_FILE" ]]; then
openssl rand -base64 32 > "$RESTIC_PASSWORD_FILE"
chmod 600 "$RESTIC_PASSWORD_FILE"
echo " Generated a new restic repository password at $RESTIC_PASSWORD_FILE."
echo " BACK THIS FILE UP SOMEWHERE ELSE — losing it makes the backup repo unreadable."
fi
cat > "$BASE_DIR/backup.env" <<EOF
RESTIC_REPOSITORY=${RESTIC_REPOSITORY}
RESTIC_PASSWORD_FILE=${RESTIC_PASSWORD_FILE}
EOF
chmod 600 "$BASE_DIR/backup.env"
cat > "$BASE_DIR/backup.sh" <<'BACKUP_EOF'
#!/usr/bin/env bash
# Backs up all stateful smart-home volumes with restic.
# Stops the stack briefly for a consistent snapshot of SQLite-backed configs
# (HA, Zigbee2MQTT, Grocy, Node-RED), then restarts it.
# Frigate's recorded video is excluded — it's large and non-critical to keep;
# face-recognition embeddings live under frigate/config, which IS backed up.
set -euo pipefail
BASE_DIR="/opt/smart-home"
source "$BASE_DIR/backup.env"
cd "$BASE_DIR"
if ! restic snapshots --no-lock >/dev/null 2>&1; then
echo "Initializing new restic repository at $RESTIC_REPOSITORY"
restic init
fi
echo "Stopping stack for a consistent backup..."
docker compose stop
restic backup "$BASE_DIR" \
--exclude "$BASE_DIR/frigate/media" \
--exclude "$BASE_DIR/.restic-password" \
--exclude "$BASE_DIR/backup.env"
echo "Restarting stack..."
docker compose start
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
BACKUP_EOF
chmod +x "$BASE_DIR/backup.sh"
cat > /etc/systemd/system/smart-home-backup.service <<EOF
[Unit]
Description=Smart home restic backup
[Service]
Type=oneshot
ExecStart=${BASE_DIR}/backup.sh
EOF
cat > /etc/systemd/system/smart-home-backup.timer <<EOF
[Unit]
Description=Daily smart home restic backup
[Timer]
OnCalendar=*-*-* ${BACKUP_SCHEDULE}:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now smart-home-backup.timer
echo " Backup timer installed: daily at ${BACKUP_SCHEDULE}, repository: ${RESTIC_REPOSITORY}"
echo " Run it manually any time with: sudo $BASE_DIR/backup.sh"
fi
# ---------------------------------------------------------------------------
# 10. Bring the stack up
# ---------------------------------------------------------------------------
echo "--- Starting containers ---"
cd "$BASE_DIR"
docker compose up -d
echo
echo "=== Done ==="
echo "Services should be reachable at:"
echo " Home Assistant : http://${HOST_IP}:8123"
echo " Zigbee2MQTT UI : http://${HOST_IP}:8080"
echo " Frigate : http://${HOST_IP}:5000"
echo " Grocy : http://${HOST_IP}:9283"
if [[ "$ENABLE_MEALIE" == "true" ]]; then
echo " Mealie : http://${HOST_IP}:9925"
fi
if [[ "$ENABLE_NODERED" == "true" ]]; then
echo " Node-RED : http://${HOST_IP}:1880"
fi
if [[ "$ENABLE_NETDATA" == "true" ]]; then
echo " Netdata : http://${HOST_IP}:19999"
fi
if [[ "$ENABLE_HOMEPAGE" == "true" ]]; then
echo " Homepage : http://${HOST_IP}:3000"
fi
if [[ "$ENABLE_NTFY" == "true" ]]; then
echo " ntfy : http://${HOST_IP}:8090"
fi
if [[ "$ENABLE_PORTAINER" == "true" ]]; then
echo " Portainer : http://${HOST_IP}:9000"
fi
echo
echo "Next steps:"
echo " 1. Edit $BASE_DIR/frigate/config/config.yml with your real peephole camera RTSP URL, then:"
echo " docker compose -f $BASE_DIR/docker-compose.yml restart frigate"
echo " 2. Check 'docker logs zigbee2mqtt' — if it can't open the serial port, confirm"
echo " ZIGBEE_USB_DEVICE at the top of this script matches 'ls -l /dev/serial/by-id/',"
echo " and that the dongle has Z-Stack coordinator firmware (not stock gateway firmware)."
echo " 3. Complete Home Assistant's onboarding wizard at :8123."
echo " 4. In HA, add the MQTT integration (it should auto-discover Zigbee2MQTT devices)."
echo " 5. Portainer: open :9000 within a few minutes of first start to set the admin password"
echo " before anyone else on the LAN can claim that instance."
echo " 6. Node-RED runs in its own bridge network, but Home Assistant uses network_mode: host,"
echo " so Node-RED can't reach it by container name — use http://${HOST_IP}:8123 plus a"
echo " long-lived access token (HA profile page) when wiring up the HA nodes."
echo " 7. Add the CalDAV integration pointing at your Nextcloud instance."
echo " 8. Point HA's Ollama conversation integration at your separate GPU/LLM host."
if [[ "$ENABLE_BACKUPS" != "true" ]]; then
echo " 9. Backups are off. Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY at the top of this"
echo " script and re-run once you have backup storage (external drive, NAS, or remote) ready."
fi
echo
echo "Updating later: cd $BASE_DIR && docker compose pull && docker compose up -d"
echo "Backing up manually: sudo $BASE_DIR/backup.sh (requires ENABLE_BACKUPS=true was run once)"