Skip to content

Instantly share code, notes, and snippets.

@rowland007
Created September 23, 2026 05:54
Show Gist options
  • Select an option

  • Save rowland007/f194975bd1904104838e3fd066278d6e to your computer and use it in GitHub Desktop.

Select an option

Save rowland007/f194975bd1904104838e3fd066278d6e to your computer and use it in GitHub Desktop.
#!/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