Created
September 23, 2026 05:54
-
-
Save rowland007/f194975bd1904104838e3fd066278d6e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -Eeuo pipefail | |
| readonly SCRIPT_NAME="$(basename "$0")" | |
| readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log" | |
| readonly JOE_USER="joe" | |
| readonly NEW_USER="randy-rowland" | |
| readonly COMPOSE_DIR="/home/${JOE_USER}/docker" | |
| readonly COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yaml" | |
| readonly DOCKERFILE="${COMPOSE_DIR}/Dockerfile" | |
| readonly ENV_FILE="${COMPOSE_DIR}/.env" | |
| readonly WHEEL_SUDOERS="/etc/sudoers.d/wheel" | |
| readonly SSH_HARDENING_FILE="/etc/ssh/sshd_config.d/99-key-only.conf" | |
| readonly RANDY_SSH_KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxFEVlJjujh1FkfW0x+K4fWLi/6WJuQIDX8s1+pBjL/' | |
| exec > >(tee -a "$LOG_FILE") 2>&1 | |
| timestamp() { | |
| date '+%Y-%m-%d %H:%M:%S' | |
| } | |
| log() { | |
| printf '\n[%s] %s\n' "$(timestamp)" "$*" | |
| } | |
| warn() { | |
| printf '\n[%s] WARNING: %s\n' "$(timestamp)" "$*" >&2 | |
| } | |
| die() { | |
| printf '\n[%s] FATAL: %s\n' "$(timestamp)" "$*" >&2 | |
| exit 1 | |
| } | |
| pause_for_repair() { | |
| local description="$1" | |
| printf '\n' | |
| printf '%s\n' "================================================================" | |
| printf 'The following operation failed:\n\n%s\n\n' "$description" | |
| printf '%s\n' "Fix the problem in another terminal, then return here." | |
| printf '%s\n' "The script will retry the operation." | |
| printf '%s\n' "Log file: $LOG_FILE" | |
| printf '%s\n' "================================================================" | |
| read -r -p "Press Enter to retry, or Ctrl-C to abort: " | |
| } | |
| run_retry() { | |
| local description="$1" | |
| shift | |
| while true; do | |
| log "$description" | |
| if "$@"; then | |
| log "Completed: $description" | |
| return 0 | |
| fi | |
| pause_for_repair "$description" | |
| done | |
| } | |
| require_root() { | |
| [[ "$EUID" -eq 0 ]] || die "Run this script as root or with sudo." | |
| } | |
| check_os() { | |
| [[ -r /etc/os-release ]] || die "Cannot identify the operating system." | |
| source /etc/os-release | |
| [[ "${ID:-}" == "debian" ]] || | |
| die "This script is intended for Debian. Detected: ${ID:-unknown}" | |
| if [[ "${VERSION_ID:-}" != "13" ]]; then | |
| warn "This script was written for Debian 13. Detected Debian ${VERSION_ID:-unknown}." | |
| read -r -p "Press Enter to continue anyway, or Ctrl-C to abort: " | |
| fi | |
| } | |
| update_os() { | |
| export DEBIAN_FRONTEND=noninteractive | |
| apt-get update | |
| apt-get -y full-upgrade | |
| apt-get -y autoclean | |
| apt-get -y autoremove | |
| } | |
| install_base_packages() { | |
| export DEBIAN_FRONTEND=noninteractive | |
| apt-get update | |
| apt-get install -y \ | |
| apt-transport-https \ | |
| ca-certificates \ | |
| curl \ | |
| cron \ | |
| gnupg \ | |
| openssh-server \ | |
| sudo \ | |
| ufw \ | |
| vim-tiny | |
| } | |
| create_groups() { | |
| getent group wheel >/dev/null || groupadd wheel | |
| getent group docker >/dev/null || groupadd docker | |
| } | |
| create_randy_user() { | |
| if id "$NEW_USER" >/dev/null 2>&1; then | |
| usermod --shell /bin/bash "$NEW_USER" | |
| usermod --home "/home/$NEW_USER" "$NEW_USER" | |
| else | |
| useradd \ | |
| --create-home \ | |
| --home-dir "/home/$NEW_USER" \ | |
| --shell /bin/bash \ | |
| --groups wheel \ | |
| "$NEW_USER" | |
| fi | |
| usermod --append --groups wheel "$NEW_USER" | |
| install -d -m 700 -o "$NEW_USER" -g "$NEW_USER" \ | |
| "/home/$NEW_USER/.ssh" | |
| printf '%s\n' "$RANDY_SSH_KEY" \ | |
| > "/home/$NEW_USER/.ssh/authorized_keys" | |
| chown "$NEW_USER:$NEW_USER" "/home/$NEW_USER/.ssh/authorized_keys" | |
| chmod 600 "/home/$NEW_USER/.ssh/authorized_keys" | |
| log "The account $NEW_USER has been created/configured." | |
| log "Set its password from another terminal if required:" | |
| log " passwd $NEW_USER" | |
| } | |
| add_users_to_docker_group() { | |
| id "$JOE_USER" >/dev/null 2>&1 || | |
| die "Required existing user '$JOE_USER' does not exist." | |
| usermod --append --groups docker "$JOE_USER" | |
| usermod --append --groups docker "$NEW_USER" | |
| } | |
| configure_wheel_sudo() { | |
| cat > "$WHEEL_SUDOERS" <<'EOF' | |
| %wheel ALL=(ALL:ALL) ALL | |
| EOF | |
| chmod 0440 "$WHEEL_SUDOERS" | |
| chown root:root "$WHEEL_SUDOERS" | |
| visudo -cf "$WHEEL_SUDOERS" | |
| } | |
| install_docker() { | |
| local codename architecture | |
| source /etc/os-release | |
| codename="${VERSION_CODENAME:-trixie}" | |
| architecture="$(dpkg --print-architecture)" | |
| 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 | |
| cat > /etc/apt/sources.list.d/docker.sources <<EOF | |
| Types: deb | |
| URIs: https://download.docker.com/linux/debian | |
| Suites: ${codename} | |
| Components: stable | |
| Architectures: ${architecture} | |
| Signed-By: /etc/apt/keyrings/docker.asc | |
| EOF | |
| apt-get update | |
| export DEBIAN_FRONTEND=noninteractive | |
| apt-get install -y \ | |
| docker-ce \ | |
| docker-ce-cli \ | |
| containerd.io \ | |
| docker-buildx-plugin \ | |
| docker-compose-plugin | |
| systemctl enable --now docker | |
| systemctl is-active --quiet docker | |
| docker version | |
| docker compose version | |
| } | |
| write_compose_file() { | |
| install -d -m 0755 "$COMPOSE_DIR" | |
| cat > "$COMPOSE_FILE" <<'EOF' | |
| services: | |
| plex: | |
| container_name: plex-media-server | |
| pull_policy: always | |
| hostname: plex | |
| image: plexinc/pms-docker:latest | |
| restart: unless-stopped | |
| ports: | |
| - "32400:32400/tcp" | |
| - "8324:8324/tcp" | |
| - "32469:32469/tcp" | |
| - "1900:1900/udp" | |
| - "32410:32410/udp" | |
| - "32412:32412/udp" | |
| - "32413:32413/udp" | |
| - "32414:32414/udp" | |
| environment: | |
| - TZ=${TIMEZONE:-Etc/UTC} | |
| - PLEX_CLAIM=${PLEX_CLAIM_TOKEN} | |
| - ADVERTISE_IP=${PLEX_URL:-http://host.docker.internal:32400/} | |
| volumes: | |
| - plex-config:/config | |
| - ${TEMP_PLEX_TRANSCODE:-/tmp}:/transcode | |
| - ${MEDIA_LIBRARIES:-./media-libraries}:/data | |
| recyclarr: | |
| image: ghcr.io/recyclarr/recyclarr:8 | |
| pull_policy: always | |
| container_name: recyclarr | |
| hostname: recyclarr | |
| user: "1000:1000" | |
| restart: unless-stopped | |
| environment: | |
| - PUID=1000 | |
| - PGID=1000 | |
| - TZ=${TIMEZONE:-Etc/UTC} | |
| volumes: | |
| - ./profiles:/config | |
| radarr: | |
| image: lscr.io/linuxserver/radarr:latest | |
| pull_policy: always | |
| container_name: radarr | |
| hostname: radarr | |
| environment: | |
| - PUID=1000 | |
| - PGID=1000 | |
| - TZ=${TIMEZONE:-Etc/UTC} | |
| volumes: | |
| - radarr-config:/config | |
| - ${MOVIE_LIBRARY:-./media-libraries/movies}:/movies | |
| - ${DOWNLOADS_PATH:-./downloads}:/downloads | |
| ports: | |
| - "7878:7878" | |
| restart: unless-stopped | |
| sonarr: | |
| image: lscr.io/linuxserver/sonarr:latest | |
| pull_policy: always | |
| container_name: sonarr | |
| hostname: sonarr | |
| environment: | |
| - PUID=1000 | |
| - PGID=1000 | |
| - TZ=${TIMEZONE:-Etc/UTC} | |
| volumes: | |
| - sonarr-config:/config | |
| - ${TV_LIBRARY:-./media-libraries/tv}:/tv | |
| - ${DOWNLOADS_PATH:-./downloads}:/downloads | |
| ports: | |
| - "8989:8989" | |
| restart: unless-stopped | |
| sabnzbd: | |
| image: lscr.io/linuxserver/sabnzbd:latest | |
| pull_policy: always | |
| container_name: sabnzbd | |
| hostname: sabnzbd | |
| environment: | |
| - PUID=1000 | |
| - PGID=1000 | |
| - TZ=${TIMEZONE:-Etc/UTC} | |
| volumes: | |
| - sabnzbd-config:/config | |
| - ${INCOMPLETE_DOWNLOADS_PATH:-./downloads/incomplete}:/incomplete-downloads | |
| - ${DOWNLOADS_PATH:-./downloads}:/downloads | |
| ports: | |
| - "8080:8080" | |
| restart: unless-stopped | |
| ombi: | |
| image: ghcr.io/linuxserver/ombi:latest | |
| pull_policy: always | |
| container_name: ombi | |
| hostname: ombi | |
| restart: unless-stopped | |
| environment: | |
| - PUID=1000 | |
| - PGID=1000 | |
| - TZ=${TIMEZONE:-Etc/UTC} | |
| volumes: | |
| - ombi-config:/config | |
| ports: | |
| - "3579:3579" | |
| depends_on: | |
| - mysql_db | |
| mysql_db: | |
| image: mysql:lts | |
| container_name: ombi_mysql | |
| hostname: ombi_mysql | |
| restart: unless-stopped | |
| environment: | |
| MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} | |
| volumes: | |
| - ombi-mysql:/var/lib/mysql | |
| phpmyadmin: | |
| image: phpmyadmin:latest | |
| container_name: ombi_phpmyadmin | |
| hostname: ombi_phpmyadmin | |
| restart: unless-stopped | |
| environment: | |
| PMA_HOST: mysql_db | |
| ports: | |
| - "80:80" | |
| depends_on: | |
| - mysql_db | |
| cloudflared: | |
| image: cloudflare/cloudflared:latest | |
| pull_policy: always | |
| restart: always | |
| command: > | |
| tunnel --no-autoupdate run --token ${CLOUDFLARED_TUNNEL_TOKEN} | |
| nordvpn: | |
| build: | |
| context: . | |
| dockerfile: Dockerfile | |
| container_name: nordvpn | |
| hostname: nordvpn | |
| cap_add: | |
| - NET_ADMIN | |
| sysctls: | |
| net.ipv6.conf.all.disable_ipv6: "0" | |
| env_file: | |
| - .env | |
| restart: unless-stopped | |
| volumes: | |
| - nordvpn-config:/etc/openvpn | |
| torrent: | |
| image: linuxserver/deluge:latest | |
| pull_policy: always | |
| container_name: torrent | |
| hostname: torrent | |
| depends_on: | |
| - nordvpn | |
| network_mode: service:nordvpn | |
| environment: | |
| - PUID=1000 | |
| - PGID=1000 | |
| - TZ=${TIMEZONE:-Etc/UTC} | |
| volumes: | |
| - torrent-config:/config | |
| - ${DOWNLOADS_PATH:-./downloads}:/downloads | |
| restart: unless-stopped | |
| volumes: | |
| plex-config: | |
| radarr-config: | |
| sonarr-config: | |
| sabnzbd-config: | |
| ombi-config: | |
| ombi-mysql: | |
| nordvpn-config: | |
| torrent-config: | |
| EOF | |
| chmod 0644 "$COMPOSE_FILE" | |
| } | |
| write_dockerfile() { | |
| cat > "$DOCKERFILE" <<'EOF' | |
| FROM ubuntu:24.04 | |
| RUN apt-get update && \ | |
| apt-get install -y --no-install-recommends \ | |
| wget \ | |
| ca-certificates \ | |
| apt-transport-https && \ | |
| wget -qO /etc/apt/trusted.gpg.d/nordvpn_public.asc \ | |
| https://repo.nordvpn.com/gpg/nordvpn_public.asc && \ | |
| echo "deb https://repo.nordvpn.com/deb/nordvpn/debian stable main" \ | |
| > /etc/apt/sources.list.d/nordvpn.list && \ | |
| apt-get update && \ | |
| apt-get install -y --no-install-recommends nordvpn && \ | |
| apt-get clean && \ | |
| rm -rf /var/lib/apt/lists/* | |
| ENTRYPOINT ["/bin/bash", "-c"] | |
| CMD ["/etc/init.d/nordvpn start && sleep 5 && exec bash"] | |
| EOF | |
| chmod 0644 "$DOCKERFILE" | |
| } | |
| write_env_file() { | |
| if [[ ! -e "$ENV_FILE" ]]; then | |
| cat > "$ENV_FILE" <<'EOF' | |
| TIMEZONE=America/New_York | |
| PLEX_CLAIM_TOKEN= | |
| PLEX_URL= | |
| TEMP_PLEX_TRANSCODE= | |
| DOWNLOADS_PATH= | |
| INCOMPLETE_DOWNLOADS_PATH= | |
| MEDIA_LIBRARIES= | |
| MOVIE_LIBRARY= | |
| TV_LIBRARY= | |
| MYSQL_ROOT_PASSWORD= | |
| CLOUDFLARED_TUNNEL_TOKEN= | |
| NORDVPN_TOKEN= | |
| EOF | |
| fi | |
| chmod 0600 "$ENV_FILE" | |
| } | |
| set_compose_ownership() { | |
| chown -R "$JOE_USER:$JOE_USER" "$COMPOSE_DIR" | |
| chmod 0600 "$ENV_FILE" | |
| } | |
| validate_compose_files() { | |
| cd "$COMPOSE_DIR" | |
| docker compose config --quiet | |
| } | |
| edit_env_pause() { | |
| cat <<EOF | |
| The Compose files have been written to: | |
| $COMPOSE_DIR | |
| Before the containers can start, edit the environment file in another terminal. | |
| nano $ENV_FILE | |
| When finished, return here. | |
| EOF | |
| read -r -p "Press Enter to continue after reviewing/editing .env: " | |
| } | |
| start_compose() { | |
| cd "$COMPOSE_DIR" | |
| docker compose config --quiet | |
| docker compose up -d | |
| } | |
| configure_ssh_key_only() { | |
| install -d -m 0755 /etc/ssh/sshd_config.d | |
| cat > "$SSH_HARDENING_FILE" <<'EOF' | |
| PubkeyAuthentication yes | |
| PasswordAuthentication no | |
| KbdInteractiveAuthentication no | |
| ChallengeResponseAuthentication no | |
| PermitRootLogin prohibit-password | |
| EOF | |
| chmod 0644 "$SSH_HARDENING_FILE" | |
| sshd -t | |
| systemctl reload ssh | |
| } | |
| configure_ufw() { | |
| ufw --force reset | |
| ufw default deny incoming | |
| ufw default allow outgoing | |
| ufw allow 22/tcp comment 'SSH' | |
| ufw allow 80/tcp comment 'phpMyAdmin' | |
| ufw allow 8080/tcp comment 'SABnzbd' | |
| ufw allow 32400/tcp comment 'Plex' | |
| ufw allow 8324/tcp comment 'Plex' | |
| ufw allow 32469/tcp comment 'Plex' | |
| ufw allow 7878/tcp comment 'Radarr' | |
| ufw allow 8989/tcp comment 'Sonarr' | |
| ufw allow 3579/tcp comment 'Ombi' | |
| ufw allow 1900/udp comment 'Plex' | |
| ufw allow 32410/udp comment 'Plex' | |
| ufw allow 32412/udp comment 'Plex' | |
| ufw allow 32413/udp comment 'Plex' | |
| ufw allow 32414/udp comment 'Plex' | |
| ufw --force enable | |
| ufw status verbose | |
| } | |
| configure_cron_jobs() { | |
| cat > /etc/cron.d/media-server-maintenance <<EOF | |
| SHELL=/bin/bash | |
| PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin | |
| 0 12 * * 5 root apt-get update && apt-get -y full-upgrade && apt-get -y autoclean && apt-get -y autoremove >> /var/log/media-server-apt.log 2>&1 | |
| 0 5 * * * root cd ${COMPOSE_DIR} && /usr/bin/docker compose pull && /usr/bin/docker compose up --build -d --remove-orphans >> /var/log/media-server-docker.log 2>&1 | |
| EOF | |
| chmod 0644 /etc/cron.d/media-server-maintenance | |
| systemctl enable --now cron | |
| } | |
| final_checks() { | |
| log "Checking services..." | |
| systemctl is-active --quiet docker | |
| systemctl is-active --quiet cron | |
| systemctl is-active --quiet ssh | |
| log "Checking sudoers configuration..." | |
| visudo -c | |
| log "Checking SSH configuration..." | |
| sshd -t | |
| log "Checking Compose configuration..." | |
| ( | |
| cd "$COMPOSE_DIR" | |
| docker compose config --quiet | |
| ) | |
| log "Checking firewall..." | |
| ufw status verbose | |
| cat <<EOF | |
| Bootstrap completed. | |
| Start a new SSH session using Randy's key: | |
| ssh -i /path/to/private-key ${NEW_USER}@SERVER_IP | |
| Start a new login session before using Docker without sudo: | |
| docker ps | |
| Review the environment file: | |
| $ENV_FILE | |
| Check the containers: | |
| cd $COMPOSE_DIR | |
| docker compose ps | |
| docker compose logs --tail=100 | |
| Cron logs: | |
| /var/log/media-server-apt.log | |
| /var/log/media-server-docker.log | |
| Full script log: | |
| $LOG_FILE | |
| EOF | |
| } | |
| main() { | |
| require_root | |
| check_os | |
| log "Starting Debian media-server bootstrap." | |
| run_retry "Update Debian and perform full upgrade" update_os | |
| run_retry "Install base packages" install_base_packages | |
| run_retry "Create wheel and docker groups" create_groups | |
| run_retry "Create/configure $NEW_USER and install SSH key" create_randy_user | |
| run_retry "Add Joe and Randy to docker group" add_users_to_docker_group | |
| run_retry "Configure password-required wheel sudo access" configure_wheel_sudo | |
| run_retry "Install Docker Engine and Docker Compose" install_docker | |
| run_retry "Write docker-compose.yaml" write_compose_file | |
| run_retry "Write Dockerfile" write_dockerfile | |
| run_retry "Write .env file" write_env_file | |
| run_retry "Set Compose directory ownership and permissions" set_compose_ownership | |
| run_retry "Validate Docker Compose configuration" validate_compose_files | |
| run_retry "Configure SSH public-key-only authentication" configure_ssh_key_only | |
| run_retry "Configure UFW firewall" configure_ufw | |
| run_retry "Configure root cron jobs" configure_cron_jobs | |
| edit_env_pause | |
| run_retry "Start Docker Compose stack" start_compose | |
| run_retry "Run final system checks" final_checks | |
| log "All operations completed successfully." | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment