#!/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" < /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" < "${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" </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 < /etc/sysctl.d/99-devlab.conf </dev/null success "System limits tuned" # ─── Save config file ───────────────────────────────────────────────────────── cat > "${LAB_ROOT}/.devlab-config" <" echo -e " A git.${DOMAIN} → " echo -e " A ai.${DOMAIN} → " echo -e " A portainer.${DOMAIN} → " echo "" echo -e "${BOLD}Useful commands${RESET}" echo -e " docker ps -a # list all containers" echo -e " docker logs -f # follow container logs" echo -e " docker exec ollama ollama list # list downloaded AI models" echo -e " docker exec ollama ollama pull # 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."