Files
DevLab/Install-devlab.sh
T
2026-06-09 21:38:35 +00:00

672 lines
24 KiB
Bash

#!/usr/bin/env bash
# =============================================================================
# DevLab Installer — Rocky Linux 10 / Hetzner Dedicated
# Installs: Docker, Caddy, code-server, Gitea, Gitea Runner,
# Ollama, Open WebUI, Portainer, Watchtower
#
# Usage:
# chmod +x install-devlab.sh
# sudo ./install-devlab.sh
#
# Environment variables (override defaults):
# DOMAIN Base domain (e.g. example.com)
# EMAIL Let's Encrypt email
# LAB_ROOT Install root (default /opt/lab)
# CODE_PASSWORD code-server password
# GITEA_ADMIN Gitea admin username
# GITEA_PASS Gitea admin password
# GITEA_EMAIL Gitea admin email
# PULL_MODELS Space-separated Ollama model list
# e.g. "codellama:34b mistral:latest"
# =============================================================================
set -euo pipefail
# ─── Colour helpers ──────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
error() { echo -e "${RED}[ERROR]${RESET} $*"; exit 1; }
header() { echo -e "\n${BOLD}${CYAN}══════════════════════════════════════════════${RESET}"; \
echo -e "${BOLD}${CYAN} $*${RESET}"; \
echo -e "${BOLD}${CYAN}══════════════════════════════════════════════${RESET}"; }
# ─── Root check ──────────────────────────────────────────────────────────────
[[ $EUID -ne 0 ]] && error "Run this script as root: sudo ./install-devlab.sh"
# ─── Interactive configuration ───────────────────────────────────────────────
header "DevLab Setup — Interactive Configuration"
prompt_val() {
local var="$1" prompt="$2" default="$3" secret="${4:-}"
if [[ -n "${!var:-}" ]]; then
info "$var already set (${!var:0:6}…)"
return
fi
if [[ "$secret" == "secret" ]]; then
read -rsp "${prompt} [default: ${default}]: " val; echo
else
read -rp "${prompt} [default: ${default}]: " val
fi
printf -v "$var" '%s' "${val:-$default}"
}
prompt_val DOMAIN "Base domain (e.g. yourdomain.com)" "lab.example.com"
prompt_val EMAIL "Let's Encrypt / admin email" "admin@${DOMAIN}"
prompt_val LAB_ROOT "Install root directory" "/opt/lab"
prompt_val CODE_PASSWORD "code-server password" "ChangeMe123!" secret
prompt_val GITEA_ADMIN "Gitea admin username" "labadmin"
prompt_val GITEA_PASS "Gitea admin password" "ChangeMe456!" secret
prompt_val GITEA_EMAIL "Gitea admin email" "${EMAIL}"
prompt_val PULL_MODELS "Ollama models to pull (space-separated, leave blank to skip)" \
"codellama:13b"
echo ""
echo -e "${BOLD}Configuration summary:${RESET}"
echo -e " Domain : ${CYAN}${DOMAIN}${RESET}"
echo -e " Email : ${CYAN}${EMAIL}${RESET}"
echo -e " Lab root : ${CYAN}${LAB_ROOT}${RESET}"
echo -e " Gitea admin: ${CYAN}${GITEA_ADMIN}${RESET}"
echo -e " Models : ${CYAN}${PULL_MODELS:-none}${RESET}"
echo ""
read -rp "Proceed with installation? [y/N]: " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
# ─── Subdomains ──────────────────────────────────────────────────────────────
CODE_HOST="code.${DOMAIN}"
GIT_HOST="git.${DOMAIN}"
AI_HOST="ai.${DOMAIN}"
PM_HOST="portainer.${DOMAIN}"
RUNNER_TOKEN="" # filled later
# ─── Directory layout ────────────────────────────────────────────────────────
header "Creating directory structure"
DIRS=(
"${LAB_ROOT}/caddy/config"
"${LAB_ROOT}/caddy/data"
"${LAB_ROOT}/workspace"
"${LAB_ROOT}/gitea"
"${LAB_ROOT}/gitea-runner"
"${LAB_ROOT}/ollama"
"${LAB_ROOT}/portainer"
"${LAB_ROOT}/open-webui"
"${LAB_ROOT}/compose"
"${LAB_ROOT}/backups"
)
for d in "${DIRS[@]}"; do mkdir -p "$d"; done
success "Directories created under ${LAB_ROOT}"
# ─── System packages ─────────────────────────────────────────────────────────
header "Installing system packages"
dnf -y update
# Enable EPEL — required for htop, btop, and other tools not in Rocky 10 base repos
info "Enabling EPEL repository..."
dnf -y install epel-release 2>/dev/null || \
dnf -y install "https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm" 2>/dev/null || \
warn "EPEL install failed — htop/btop may not be available"
dnf -y makecache 2>/dev/null || true
# Core packages — all available in base repos (must succeed)
dnf -y install \
curl wget git vim unzip tar \
firewalld bash-completion \
ca-certificates gnupg2 \
python3 python3-pip \
net-tools lsof
# Optional monitoring tools — from EPEL, non-fatal if unavailable
dnf -y install htop 2>/dev/null && success "htop installed" || \
dnf -y install btop 2>/dev/null && success "btop installed (htop unavailable)" || \
warn "htop/btop not available — skipping (optional)"
success "System packages installed"
# ─── Firewall ─────────────────────────────────────────────────────────────────
header "Configuring firewall"
systemctl enable --now firewalld
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --permanent --add-service=ssh
# Remove default open ports that Hetzner sometimes pre-opens
firewall-cmd --permanent --remove-service=cockpit 2>/dev/null || true
firewall-cmd --reload
success "Firewall configured (HTTP, HTTPS, SSH)"
# ─── Docker ──────────────────────────────────────────────────────────────────
header "Installing Docker CE"
if command -v docker &>/dev/null; then
warn "Docker already installed — skipping"
else
dnf -y install dnf-plugins-core
dnf config-manager --add-repo \
https://download.docker.com/linux/rhel/docker-ce.repo
dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin
systemctl enable --now docker
success "Docker CE installed"
fi
# Ensure docker group exists and add current SUDO_USER if set
if [[ -n "${SUDO_USER:-}" ]]; then
usermod -aG docker "$SUDO_USER"
info "Added ${SUDO_USER} to docker group"
fi
# ─── Docker network ───────────────────────────────────────────────────────────
docker network create devlab 2>/dev/null || true
success "Docker network 'devlab' ready"
# ─── Caddy (reverse proxy + auto-SSL) ────────────────────────────────────────
header "Deploying Caddy (reverse proxy + Let's Encrypt)"
cat > "${LAB_ROOT}/caddy/config/Caddyfile" <<EOF
{
email ${EMAIL}
}
${CODE_HOST} {
reverse_proxy code-server:8080
}
${GIT_HOST} {
reverse_proxy gitea:3000
}
${AI_HOST} {
reverse_proxy open-webui:8080
}
${PM_HOST} {
reverse_proxy portainer:9000
}
EOF
docker run -d \
--name caddy \
--restart unless-stopped \
--network devlab \
-p 80:80 \
-p 443:443 \
-v "${LAB_ROOT}/caddy/config/Caddyfile:/etc/caddy/Caddyfile:ro" \
-v "${LAB_ROOT}/caddy/data:/data" \
-v "${LAB_ROOT}/caddy/config:/config" \
caddy:latest
success "Caddy running"
# ─── code-server ─────────────────────────────────────────────────────────────
header "Deploying code-server (web IDE)"
# Build custom image with multi-language runtimes
cat > /tmp/code-server.Dockerfile <<'DOCKERFILE'
FROM codercom/code-server:latest
USER root
# Node.js (via NodeSource)
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs
# Python extras
RUN apt-get install -y python3-pip python3-venv python3-full
# Go
RUN curl -fsSL https://go.dev/dl/go1.23.0.linux-amd64.tar.gz \
| tar -C /usr/local -xz
ENV PATH="/usr/local/go/bin:${PATH}"
# Rust
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --no-modify-path
ENV PATH="/root/.cargo/bin:${PATH}"
# PHP, Ruby, Java, C/C++ tools
RUN apt-get install -y \
php-cli php-curl php-mbstring \
ruby-full \
default-jdk-headless \
gcc g++ cmake make \
jq tree ripgrep fd-find fzf
# Cleanup
RUN apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/*
USER coder
# Pre-install popular VS Code extensions
RUN code-server --install-extension ms-python.python \
--install-extension golang.go \
--install-extension rust-lang.rust-analyzer \
--install-extension dbaeumer.vscode-eslint \
--install-extension esbenp.prettier-vscode \
--install-extension eamodio.gitlens \
--install-extension ms-azuretools.vscode-docker \
--install-extension PKief.material-icon-theme \
2>/dev/null || true
DOCKERFILE
info "Building code-server image with multi-language runtimes (this takes a few minutes)..."
docker build -t devlab/code-server:latest -f /tmp/code-server.Dockerfile /tmp/
docker run -d \
--name code-server \
--restart unless-stopped \
--network devlab \
-e PASSWORD="${CODE_PASSWORD}" \
-e DOCKER_HOST=unix:///var/run/docker.sock \
-v "${LAB_ROOT}/workspace:/home/coder/workspace" \
-v /var/run/docker.sock:/var/run/docker.sock \
devlab/code-server:latest
success "code-server running → https://${CODE_HOST}"
# ─── Gitea ────────────────────────────────────────────────────────────────────
header "Deploying Gitea (self-hosted Git)"
docker run -d \
--name gitea \
--restart unless-stopped \
--network devlab \
-e USER_UID=1000 \
-e USER_GID=1000 \
-e GITEA__database__DB_TYPE=sqlite3 \
-e GITEA__server__DOMAIN="${GIT_HOST}" \
-e GITEA__server__ROOT_URL="https://${GIT_HOST}" \
-e GITEA__server__SSH_PORT=2222 \
-e GITEA__server__HTTP_PORT=3000 \
-e GITEA__service__DISABLE_REGISTRATION=true \
-e GITEA__service__REQUIRE_SIGNIN_VIEW=false \
-e GITEA__log__LEVEL=Info \
-e GITEA__actions__ENABLED=true \
-v "${LAB_ROOT}/gitea:/data" \
-p 2222:22 \
gitea/gitea:latest
# Wait for Gitea to boot
info "Waiting for Gitea to initialise..."
sleep 15
# Create admin user via gitea CLI inside the container
docker exec -u git gitea \
gitea admin user create \
--username "${GITEA_ADMIN}" \
--password "${GITEA_PASS}" \
--email "${GITEA_EMAIL}" \
--admin \
--must-change-password=false 2>/dev/null || \
warn "Gitea admin user may already exist — skipping"
success "Gitea running → https://${GIT_HOST}"
# ─── Gitea Actions Runner ─────────────────────────────────────────────────────
header "Deploying Gitea Actions Runner (CI/CD)"
# Generate a runner token via Gitea API
info "Generating runner registration token..."
sleep 5
RUNNER_TOKEN=$(docker exec -u git gitea \
gitea actions generate-runner-token 2>/dev/null | tail -1) || true
if [[ -z "${RUNNER_TOKEN}" ]]; then
warn "Could not auto-generate runner token — you will need to register manually."
warn "See documentation section 7 for instructions."
RUNNER_TOKEN="NEEDS_MANUAL_REGISTRATION"
fi
# Write runner config
cat > "${LAB_ROOT}/gitea-runner/config.yml" <<EOF
log:
level: info
runner:
file: .runner
capacity: 5
envs: {}
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
cache:
enabled: true
dir: ""
host: ""
port: 0
container:
network: "devlab"
enable_ipv6: false
valid_volumes:
- /opt/lab/**
EOF
docker run -d \
--name gitea-runner \
--restart unless-stopped \
--network devlab \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${LAB_ROOT}/gitea-runner:/data" \
-e GITEA_INSTANCE_URL="http://gitea:3000" \
-e GITEA_RUNNER_REGISTRATION_TOKEN="${RUNNER_TOKEN}" \
-e GITEA_RUNNER_NAME="devlab-runner-1" \
gitea/act_runner:latest
success "Gitea Actions Runner deployed"
# ─── Ollama ───────────────────────────────────────────────────────────────────
header "Deploying Ollama (local AI runtime)"
docker run -d \
--name ollama \
--restart unless-stopped \
--network devlab \
-v "${LAB_ROOT}/ollama:/root/.ollama" \
-p 127.0.0.1:11434:11434 \
ollama/ollama:latest
success "Ollama running on localhost:11434"
# Pull models if requested
if [[ -n "${PULL_MODELS}" ]]; then
info "Pulling AI models in background: ${PULL_MODELS}"
for model in $PULL_MODELS; do
docker exec ollama ollama pull "$model" &
info " → pulling ${model} (background)"
done
success "Model pulls started (run 'docker exec ollama ollama list' to check progress)"
fi
# ─── Open WebUI ───────────────────────────────────────────────────────────────
header "Deploying Open WebUI (AI chat interface)"
docker run -d \
--name open-webui \
--restart unless-stopped \
--network devlab \
-e OLLAMA_BASE_URL=http://ollama:11434 \
-e WEBUI_SECRET_KEY="$(openssl rand -hex 32)" \
-v "${LAB_ROOT}/open-webui:/app/backend/data" \
ghcr.io/open-webui/open-webui:main
success "Open WebUI running → https://${AI_HOST}"
# ─── Portainer ────────────────────────────────────────────────────────────────
header "Deploying Portainer (container management UI)"
docker run -d \
--name portainer \
--restart unless-stopped \
--network devlab \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${LAB_ROOT}/portainer:/data" \
portainer/portainer-ce:latest
success "Portainer running → https://${PM_HOST}"
# ─── Watchtower (auto-updates) ────────────────────────────────────────────────
header "Deploying Watchtower (automatic container updates)"
docker run -d \
--name watchtower \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower \
--schedule "0 0 4 * * *" \
--cleanup \
--label-enable
success "Watchtower scheduled (04:00 daily)"
# ─── Docker Compose stacks helper ────────────────────────────────────────────
header "Creating example test environment templates"
mkdir -p "${LAB_ROOT}/compose/examples"
# Example: full LAMP stack for testing
cat > "${LAB_ROOT}/compose/examples/lamp-stack.yml" <<'YAML'
# Example test environment: LAMP stack
# Usage: docker compose -f lamp-stack.yml up -d
# Access: http://localhost:8888
services:
web:
image: php:8.3-apache
ports: ["8888:80"]
volumes: ["./app:/var/www/html"]
networks: [test]
db:
image: mariadb:11
environment:
MYSQL_ROOT_PASSWORD: testpass
MYSQL_DATABASE: testdb
volumes: ["dbdata:/var/lib/mysql"]
networks: [test]
phpmyadmin:
image: phpmyadmin:latest
ports: ["8889:80"]
environment:
PMA_HOST: db
networks: [test]
volumes:
dbdata:
networks:
test:
YAML
# Example: Node.js + PostgreSQL
cat > "${LAB_ROOT}/compose/examples/node-postgres.yml" <<'YAML'
# Example test environment: Node.js + PostgreSQL + Redis
# Usage: docker compose -f node-postgres.yml up -d
services:
app:
image: node:22-alpine
working_dir: /app
volumes: ["./app:/app"]
command: sh -c "npm install && npm start"
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://user:pass@db/appdb
REDIS_URL: redis://cache:6379
networks: [test]
depends_on: [db, cache]
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: appdb
volumes: ["pgdata:/var/lib/postgresql/data"]
networks: [test]
cache:
image: redis:7-alpine
networks: [test]
volumes:
pgdata:
networks:
test:
YAML
# Example: Django + MySQL
cat > "${LAB_ROOT}/compose/examples/django-mysql.yml" <<'YAML'
# Example test environment: Django + MySQL
services:
web:
image: python:3.12-slim
working_dir: /app
volumes: ["./app:/app"]
command: sh -c "pip install -r requirements.txt && python manage.py runserver 0.0.0.0:8000"
ports: ["8000:8000"]
environment:
DATABASE_URL: mysql://user:pass@db/appdb
networks: [test]
depends_on: [db]
db:
image: mysql:8.3
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: appdb
MYSQL_USER: user
MYSQL_PASSWORD: pass
volumes: ["mysqldata:/var/lib/mysql"]
networks: [test]
volumes:
mysqldata:
networks:
test:
YAML
success "Example compose stacks written to ${LAB_ROOT}/compose/examples/"
# ─── Gitea workflow example ───────────────────────────────────────────────────
mkdir -p "${LAB_ROOT}/workspace/.gitea/workflows"
cat > "${LAB_ROOT}/workspace/.gitea/workflows/ci.yml" <<'YAML'
# Example Gitea Actions CI workflow
# Place this in your repo at: .gitea/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest --tb=short
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint with ruff
run: pip install ruff && ruff check .
docker-build:
runs-on: ubuntu-latest
needs: [test, lint]
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
YAML
success "Example CI workflow written"
# ─── Backup script ────────────────────────────────────────────────────────────
header "Creating backup script"
cat > "${LAB_ROOT}/backup.sh" <<BACKUP
#!/usr/bin/env bash
# DevLab backup script — uses rsync to /opt/lab/backups/
# Schedule with: crontab -e → 0 3 * * * /opt/lab/backup.sh
set -euo pipefail
BACKUP_DIR="${LAB_ROOT}/backups/\$(date +%Y-%m-%d)"
mkdir -p "\$BACKUP_DIR"
echo "[BACKUP] Starting at \$(date)"
# Stop containers for consistent snapshots (optional — comment out for hot backup)
# docker stop gitea code-server open-webui portainer 2>/dev/null
rsync -a --exclude='ollama' "${LAB_ROOT}/" "\$BACKUP_DIR/" \
--exclude='backups' --exclude='*.log'
# Restart if stopped
# docker start gitea code-server open-webui portainer 2>/dev/null
# Prune backups older than 14 days
find "${LAB_ROOT}/backups" -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true
echo "[BACKUP] Done → \$BACKUP_DIR"
BACKUP
chmod +x "${LAB_ROOT}/backup.sh"
# Install cron job for daily backup at 03:00
(crontab -l 2>/dev/null | grep -v backup.sh; echo "0 3 * * * ${LAB_ROOT}/backup.sh >> ${LAB_ROOT}/backups/backup.log 2>&1") | crontab -
success "Backup script installed (runs daily at 03:00)"
# ─── System tuning ────────────────────────────────────────────────────────────
header "Applying system optimisations"
# Increase file descriptor limits for Docker
cat > /etc/security/limits.d/99-devlab.conf <<EOF
* soft nofile 65536
* hard nofile 65536
root soft nofile 65536
root hard nofile 65536
EOF
# Kernel network tuning
cat > /etc/sysctl.d/99-devlab.conf <<EOF
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
vm.max_map_count = 262144
fs.file-max = 2097152
EOF
sysctl --system &>/dev/null
success "System limits tuned"
# ─── Save config file ─────────────────────────────────────────────────────────
cat > "${LAB_ROOT}/.devlab-config" <<CONFIG
# DevLab configuration — generated $(date)
DOMAIN=${DOMAIN}
EMAIL=${EMAIL}
LAB_ROOT=${LAB_ROOT}
CODE_HOST=${CODE_HOST}
GIT_HOST=${GIT_HOST}
AI_HOST=${AI_HOST}
PM_HOST=${PM_HOST}
GITEA_ADMIN=${GITEA_ADMIN}
GITEA_EMAIL=${GITEA_EMAIL}
PULL_MODELS=${PULL_MODELS}
INSTALLED=$(date -Iseconds)
CONFIG
chmod 600 "${LAB_ROOT}/.devlab-config"
# ─── Final status ─────────────────────────────────────────────────────────────
header "Installation Complete"
echo ""
echo -e "${BOLD}Service URLs${RESET}"
echo -e " Web IDE → ${GREEN}https://${CODE_HOST}${RESET}"
echo -e " Git (Gitea) → ${GREEN}https://${GIT_HOST}${RESET}"
echo -e " AI Chat → ${GREEN}https://${AI_HOST}${RESET}"
echo -e " Containers UI → ${GREEN}https://${PM_HOST}${RESET}"
echo ""
echo -e "${BOLD}Credentials${RESET}"
echo -e " code-server password : ${CYAN}${CODE_PASSWORD}${RESET}"
echo -e " Gitea admin : ${CYAN}${GITEA_ADMIN} / ${GITEA_PASS}${RESET}"
echo ""
echo -e "${BOLD}SSH Git access${RESET}"
echo -e " git clone ssh://git@${DOMAIN}:2222/username/repo.git"
echo ""
echo -e "${BOLD}DNS records required${RESET}"
echo -e " A code.${DOMAIN} → <this server IP>"
echo -e " A git.${DOMAIN} → <this server IP>"
echo -e " A ai.${DOMAIN} → <this server IP>"
echo -e " A portainer.${DOMAIN} → <this server IP>"
echo ""
echo -e "${BOLD}Useful commands${RESET}"
echo -e " docker ps -a # list all containers"
echo -e " docker logs -f <name> # follow container logs"
echo -e " docker exec ollama ollama list # list downloaded AI models"
echo -e " docker exec ollama ollama pull <m> # pull new AI model"
echo -e " ${LAB_ROOT}/backup.sh # run manual backup"
echo ""
echo -e "${YELLOW}⚠ Point your DNS A records before SSL certs can be issued.${RESET}"
echo -e "${YELLOW}⚠ If you were added to the docker group, log out and back in.${RESET}"
echo ""
success "DevLab is ready! Enjoy your lab, Harry."