forgejo-autohebergement/setup-wizard.sh

1327 lines
42 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#
# Forgejo Self-Hosting Setup Wizard
# Interactive setup script for deploying Forgejo on Scaleway or Hetzner
#
# This wizard guides you through:
# 1. Checking dependencies
# 2. Configuring secrets (Ansible Vault)
# 3. Setting up cloud provider credentials
# 4. Creating infrastructure (Terraform/Terragrunt)
# 5. Configuring Ansible inventory
# 6. Deploying Forgejo with proper Tailscale/UFW ordering
#
# Works on: macOS, Debian, Ubuntu
#
set -euo pipefail
# ==============================================================================
# Configuration
# ==============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ANSIBLE_DIR="${SCRIPT_DIR}/ansible"
TERRAFORM_DIR="${SCRIPT_DIR}/terraform"
INVENTORY_FILE="${ANSIBLE_DIR}/inventory/production/hosts.yml"
SECRETS_FILE="${ANSIBLE_DIR}/playbooks/vars/secrets.yml"
SECRETS_EXAMPLE="${ANSIBLE_DIR}/playbooks/vars/secrets.yml.example"
STATE_FILE="${SCRIPT_DIR}/.wizard-state"
# Point Ansible to our config file
export ANSIBLE_CONFIG="${ANSIBLE_DIR}/ansible.cfg"
# State tracking (compatible with bash 3.x - no associative arrays)
VAULT_PASSWORD=""
load_state() {
# State is read directly from file when needed (bash 3.x compatible)
true
}
save_state() {
local key="$1"
local value="$2"
# Create file if it doesn't exist
if [[ ! -f "$STATE_FILE" ]]; then
echo "# Wizard state - auto-generated" >"$STATE_FILE"
fi
# Remove existing key if present, then add new value
if grep -q "^${key}=" "$STATE_FILE" 2>/dev/null; then
# Key exists, update it (macOS/Linux compatible)
local temp_file="${STATE_FILE}.tmp"
grep -v "^${key}=" "$STATE_FILE" >"$temp_file"
echo "${key}=${value}" >>"$temp_file"
mv "$temp_file" "$STATE_FILE"
else
# Key doesn't exist, append it
echo "${key}=${value}" >>"$STATE_FILE"
fi
}
get_state() {
local key="$1"
if [[ -f "$STATE_FILE" ]]; then
# Use || true to prevent exit on grep finding no matches (exit code 1)
grep "^${key}=" "$STATE_FILE" 2>/dev/null | cut -d'=' -f2- | head -1 || true
fi
}
# Check if a step is marked as "done"
is_step_done() {
[[ "$(get_state "$1")" == "done" ]]
}
# Check if a step has any value set (for steps that store actual values like provider, domain)
has_state() {
local value
value="$(get_state "$1")"
[[ -n "$value" ]]
}
# ==============================================================================
# Colors and Formatting (works on macOS and Linux)
# ==============================================================================
# Check if terminal supports colors
if [[ -t 1 ]] && command -v tput &>/dev/null && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
RED=$(tput setaf 1)
GREEN=$(tput setaf 2)
YELLOW=$(tput setaf 3)
BLUE=$(tput setaf 4)
MAGENTA=$(tput setaf 5)
CYAN=$(tput setaf 6)
BOLD=$(tput bold)
DIM=$(tput dim)
RESET=$(tput sgr0)
else
RED=""
GREEN=""
YELLOW=""
BLUE=""
MAGENTA=""
CYAN=""
BOLD=""
DIM=""
RESET=""
fi
# ==============================================================================
# Helper Functions
# ==============================================================================
print_banner() {
clear
echo "${CYAN}${BOLD}"
echo "╔═══════════════════════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗ ██╗ ██████╗ ║"
echo "║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝ ██║██╔═══██╗ ║"
echo "║ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ██║██║ ██║ ║"
echo "║ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ██ ██║██║ ██║ ║"
echo "║ ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗╚█████╔╝╚██████╔╝ ║"
echo "║ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚════╝ ╚═════╝ ║"
echo "║ ║"
echo "║ Self-Hosting Setup Wizard (by Dumontix) ║"
echo "║ ║"
echo "╚═══════════════════════════════════════════════════════════════════════════╝"
echo "${RESET}"
echo ""
}
print_step() {
local step_num="$1"
local step_title="$2"
# Clear screen for each new step (cleaner experience)
clear
# Show mini header
echo "${CYAN}${BOLD}Forgejo Setup Wizard${RESET}"
echo ""
echo "${BLUE}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo "${BLUE}${BOLD} Step ${step_num}: ${step_title}${RESET}"
echo "${BLUE}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo ""
}
print_info() {
echo "${CYAN} ${RESET}$1"
}
print_success() {
echo "${GREEN}${RESET}$1"
}
print_warning() {
echo "${YELLOW}${RESET}$1"
}
print_error() {
echo "${RED}${RESET}$1"
}
print_action() {
echo "${MAGENTA}${RESET}$1"
}
press_enter() {
echo ""
echo -n "${DIM}Press Enter to continue...${RESET}"
read -r </dev/tty
}
ask_yes_no() {
local prompt="$1"
local default="${2:-y}"
local yn
if [[ "$default" == "y" ]]; then
prompt="${prompt} [Y/n]: "
else
prompt="${prompt} [y/N]: "
fi
echo -n "${YELLOW}? ${RESET}${prompt}"
read -r yn </dev/tty
yn="${yn:-$default}"
# Case-insensitive comparison (compatible with bash 3.x on macOS)
case "$yn" in
[Yy] | [Yy][Ee][Ss]) return 0 ;;
*) return 1 ;;
esac
}
ask_input() {
local prompt="$1"
local default="${2:-}"
local result
# Output prompt to stderr so it's not captured by $()
if [[ -n "$default" ]]; then
echo -n "${YELLOW}? ${RESET}${prompt} [${default}]: " >&2
else
echo -n "${YELLOW}? ${RESET}${prompt}: " >&2
fi
read -r result </dev/tty
echo "${result:-$default}"
}
# Ask for sensitive input (no echo)
ask_secret() {
local prompt="$1"
local result
echo -n "${YELLOW}? ${RESET}${prompt}: " >&2
read -rs result </dev/tty
echo "" >&2 # New line after hidden input
echo "$result"
}
ask_choice() {
local prompt="$1"
shift
local options=("$@")
local choice
# Output menu to stderr so it's not captured by $()
echo "${YELLOW}? ${RESET}${prompt}" >&2
for i in "${!options[@]}"; do
echo " ${CYAN}$((i + 1))${RESET}) ${options[$i]}" >&2
done
echo -n " Enter choice [1-${#options[@]}]: " >&2
# Read from /dev/tty to ensure we read from terminal
read -r choice </dev/tty
if [[ "$choice" =~ ^[0-9]+$ ]] && [[ "$choice" -ge 1 ]] && [[ "$choice" -le "${#options[@]}" ]]; then
echo "$((choice - 1))"
else
echo "0"
fi
}
generate_password() {
# Works on both macOS and Linux
if command -v openssl &>/dev/null; then
openssl rand -base64 "$1" | tr -d '\n'
elif [[ -r /dev/urandom ]]; then
head -c "$1" /dev/urandom | base64 | tr -d '\n' | head -c "$1"
else
print_error "Cannot generate random password"
exit 1
fi
}
open_editor() {
local file="$1"
local editor="${EDITOR:-${VISUAL:-}}"
# Try to find a suitable editor
if [[ -z "$editor" ]]; then
for cmd in nano vim vi; do
if command -v "$cmd" &>/dev/null; then
editor="$cmd"
break
fi
done
fi
if [[ -z "$editor" ]]; then
print_error "No editor found. Please set \$EDITOR environment variable."
print_info "For now, please edit the file manually: ${file}"
press_enter
return 1
fi
print_action "Opening ${file} in ${editor}..."
"$editor" "$file"
}
run_command() {
local description="$1"
shift
print_action "${description}..."
echo "${DIM} \$ $*${RESET}"
if "$@"; then
print_success "${description} completed"
return 0
else
print_error "${description} failed"
return 1
fi
}
# ==============================================================================
# Step Functions
# ==============================================================================
check_dependencies() {
print_step "1" "Checking Dependencies"
local missing=()
local deps=(terraform terragrunt ansible ansible-playbook ansible-vault make ssh ssh-keygen)
for dep in "${deps[@]}"; do
if command -v "$dep" &>/dev/null; then
print_success "$dep found: $(command -v "$dep")"
else
print_error "$dep not found"
missing+=("$dep")
fi
done
echo ""
if [[ ${#missing[@]} -gt 0 ]]; then
echo ""
print_error "Missing dependencies: ${missing[*]}"
echo ""
print_info "Please install the missing tools:"
echo ""
echo " ${BOLD}macOS (with Homebrew):${RESET}"
echo " brew install terraform terragrunt ansible make"
echo ""
echo " ${BOLD}Ubuntu/Debian:${RESET}"
echo " sudo apt update"
echo " sudo apt install make ansible"
echo " # Install terraform and terragrunt from their websites"
echo ""
echo " ${BOLD}on a Python virtualenv:${RESET} (likely can be done with UV, I am not sure how)"
echo " python3 -m venv .venv;. .venv/bin/activate"
echo " python3 -m pip install ansible"
echo " # Install terraform and terragrunt from their websites"
echo ""
exit 1
fi
print_success "All dependencies satisfied!"
}
select_ssh_key() {
print_step "1b" "Select SSH Key"
# Find all SSH keys (private keys without .pub extension that have a matching .pub)
local ssh_keys=()
local key_names=()
for key in ~/.ssh/id_* ~/.ssh/*.pem; do
# Skip if glob didn't match
[[ -e "$key" ]] || continue
# Skip public keys
[[ "$key" == *.pub ]] && continue
# Skip known_hosts and config files
[[ "$key" == *known_hosts* ]] && continue
[[ "$key" == *config* ]] && continue
[[ "$key" == *authorized_keys* ]] && continue
# Check if it's a file (not directory)
[[ -f "$key" ]] || continue
ssh_keys+=("$key")
# Get a friendly name (just the filename)
key_names+=("$(basename "$key")")
done
if [[ ${#ssh_keys[@]} -eq 0 ]]; then
print_warning "No SSH keys found in ~/.ssh/"
if ask_yes_no "Generate a new SSH key?"; then
local key_name
key_name=$(ask_input "Key name" "id_ed25519")
ssh-keygen -t ed25519 -f ~/.ssh/"$key_name" -N ""
SSH_KEY=~/.ssh/"$key_name"
print_success "SSH key generated: ${SSH_KEY}"
else
print_error "SSH key is required for server access"
exit 1
fi
elif [[ ${#ssh_keys[@]} -eq 1 ]]; then
SSH_KEY="${ssh_keys[0]}"
print_info "Using only available SSH key: ${SSH_KEY}"
else
print_info "Found ${#ssh_keys[@]} SSH keys:"
echo ""
# Display keys with numbers
local i=1
for key in "${ssh_keys[@]}"; do
local pub_key="${key}.pub"
local key_info=""
if [[ -f "$pub_key" ]]; then
# Extract comment/email from public key
key_info=$(awk '{print $3}' "$pub_key" 2>/dev/null || true)
fi
if [[ -n "$key_info" ]]; then
echo " ${CYAN}${i}${RESET}) $(basename "$key") ${DIM}(${key_info})${RESET}"
else
echo " ${CYAN}${i}${RESET}) $(basename "$key")"
fi
i=$((i + 1))
done
echo ""
local choice
echo -n " Select SSH key [1-${#ssh_keys[@]}]: " >&2
read -r choice </dev/tty
if [[ "$choice" =~ ^[0-9]+$ ]] && [[ "$choice" -ge 1 ]] && [[ "$choice" -le "${#ssh_keys[@]}" ]]; then
SSH_KEY="${ssh_keys[$((choice - 1))]}"
else
SSH_KEY="${ssh_keys[0]}"
print_warning "Invalid choice, using first key"
fi
fi
print_success "Selected SSH key: ${SSH_KEY}"
export SSH_KEY
# Read the public key content for cloud provider registration
local pub_key_file="${SSH_KEY}.pub"
if [[ -f "$pub_key_file" ]]; then
SSH_PUBLIC_KEY=$(cat "$pub_key_file")
export SSH_PUBLIC_KEY
print_success "Public key loaded from ${pub_key_file}"
else
print_warning "No .pub file found for this key"
print_info "You may need to manually add your SSH key to your cloud provider"
fi
}
choose_provider() {
print_step "2" "Choose Cloud Provider"
print_info "This setup supports two European cloud providers:"
echo ""
echo " ${BOLD}Scaleway${RESET} (France) - ~€9/month (as of Jan 8 2026)"
echo " - DEV1-M: 4GB RAM, 2 vCPUs, 40GB SSD"
echo " - Object Storage for LFS and backups"
echo ""
echo " ${BOLD}Hetzner${RESET} (Germany) - ~€8/month (as of Jan 8 2026)"
echo " - CPX21: 4 vCPUs, 8GB RAM"
echo " - Excellent price/performance"
echo ""
local choice
choice=$(ask_choice "Select your cloud provider:" "Scaleway" "Hetzner")
if [[ "$choice" == "0" ]]; then
PROVIDER="scaleway"
else
PROVIDER="hetzner"
fi
print_success "Selected provider: ${PROVIDER}"
export PROVIDER
}
is_vault_encrypted() {
local file="$1"
[[ -f "$file" ]] && head -1 "$file" 2>/dev/null | grep -q '^\$ANSIBLE_VAULT'
}
read_vault_secret() {
local file="$1"
local key="$2"
local password="$3"
# Decrypt and extract value using ansible-vault
echo "$password" | ansible-vault view "$file" --vault-password-file=/dev/stdin 2>/dev/null | grep "^${key}:" | sed "s/^${key}:[[:space:]]*//" | tr -d '"' | tr -d "'"
}
configure_secrets() {
print_step "3" "Configure Secrets"
# Check if secrets file exists and is encrypted
if [[ -f "$SECRETS_FILE" ]] && is_vault_encrypted "$SECRETS_FILE"; then
print_info "Encrypted secrets file found: ${SECRETS_FILE}"
echo ""
local choice
choice=$(ask_choice "What would you like to do?" "Use existing secrets (enter vault password to view admin password)" "Generate new secrets (overwrites existing)")
if [[ "$choice" == "0" ]]; then
# Read existing secrets
print_info "Enter your Ansible Vault password to read existing secrets."
echo ""
local vault_pass
vault_pass=$(ask_secret "Vault password")
if [[ -z "$vault_pass" ]]; then
print_error "No password entered"
return 1
fi
# Verify password by trying to view the file
local admin_password
admin_password=$(read_vault_secret "$SECRETS_FILE" "vault_forgejo_admin_password" "$vault_pass")
if [[ -z "$admin_password" ]]; then
print_error "Could not decrypt secrets. Wrong password?"
if ask_yes_no "Try again?" "y"; then
configure_secrets
return $?
fi
return 1
fi
# Show admin password
echo ""
echo "${GREEN}${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}"
echo "${GREEN}${BOLD}│ Secrets decrypted successfully! │${RESET}"
echo "${GREEN}${BOLD}│ │${RESET}"
echo "${GREEN}${BOLD}│ Admin Username: admin │${RESET}"
echo "${GREEN}${BOLD}│ Admin Password: ${admin_password} ${RESET}"
echo "${GREEN}${BOLD}│ │${RESET}"
echo "${GREEN}${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}"
echo ""
print_success "Using existing secrets"
return 0
fi
# Fall through to generate new secrets
print_warning "Existing secrets will be overwritten."
fi
print_info "Secrets are stored encrypted with Ansible Vault."
print_info "You'll need to remember your vault password!"
echo ""
# Copy example file
if [[ -f "$SECRETS_EXAMPLE" ]]; then
cp "$SECRETS_EXAMPLE" "$SECRETS_FILE"
else
print_error "Secrets example file not found: ${SECRETS_EXAMPLE}"
exit 1
fi
# Generate passwords
print_action "Generating secure passwords..."
local db_password
local admin_password
local secret_key
local internal_token
local jwt_secret
local metrics_token
db_password=$(generate_password 32)
admin_password=$(generate_password 24)
secret_key=$(generate_password 48)
internal_token=$(generate_password 48)
jwt_secret=$(generate_password 32)
metrics_token=$(generate_password 24)
# Update secrets file
if [[ "$(uname)" == "Darwin" ]]; then
# macOS sed requires different syntax
sed -i '' "s|CHANGE_ME_STRONG_PASSWORD_HERE|${db_password}|g" "$SECRETS_FILE"
sed -i '' "s|CHANGE_ME_ADMIN_PASSWORD_HERE|${admin_password}|g" "$SECRETS_FILE"
sed -i '' "s|CHANGE_ME_SECRET_KEY_64_CHARS_MINIMUM_XXXXXXXXXXXXXXXXX|${secret_key}|g" "$SECRETS_FILE"
sed -i '' "s|CHANGE_ME_INTERNAL_TOKEN_XXXXXXXXXXXXXXXXXXXXXXXXX|${internal_token}|g" "$SECRETS_FILE"
sed -i '' "s|CHANGE_ME_JWT_SECRET_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX|${jwt_secret}|g" "$SECRETS_FILE"
sed -i '' "s|CHANGE_ME_METRICS_TOKEN_XXXXXXXXX|${metrics_token}|g" "$SECRETS_FILE"
else
# Linux sed
sed -i "s|CHANGE_ME_STRONG_PASSWORD_HERE|${db_password}|g" "$SECRETS_FILE"
sed -i "s|CHANGE_ME_ADMIN_PASSWORD_HERE|${admin_password}|g" "$SECRETS_FILE"
sed -i "s|CHANGE_ME_SECRET_KEY_64_CHARS_MINIMUM_XXXXXXXXXXXXXXXXX|${secret_key}|g" "$SECRETS_FILE"
sed -i "s|CHANGE_ME_INTERNAL_TOKEN_XXXXXXXXXXXXXXXXXXXXXXXXX|${internal_token}|g" "$SECRETS_FILE"
sed -i "s|CHANGE_ME_JWT_SECRET_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX|${jwt_secret}|g" "$SECRETS_FILE"
sed -i "s|CHANGE_ME_METRICS_TOKEN_XXXXXXXXX|${metrics_token}|g" "$SECRETS_FILE"
fi
print_success "Passwords generated and saved"
# Show admin password
echo ""
echo "${YELLOW}${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}"
echo "${YELLOW}${BOLD}│ IMPORTANT: Save your admin password! │${RESET}"
echo "${YELLOW}${BOLD}│ │${RESET}"
echo "${YELLOW}${BOLD}│ Admin Username: admin │${RESET}"
echo "${YELLOW}${BOLD}│ Admin Password: ${admin_password}${RESET}"
echo "${YELLOW}${BOLD}│ │${RESET}"
echo "${YELLOW}${BOLD}│ You can change this later in the Forgejo web interface. │${RESET}"
echo "${YELLOW}${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}"
echo ""
# Encrypt with Ansible Vault
print_info "Now you need to encrypt the secrets file with Ansible Vault."
print_info "Choose a strong password and remember it!"
echo ""
if ansible-vault encrypt "$SECRETS_FILE"; then
print_success "Secrets encrypted successfully"
else
print_error "Failed to encrypt secrets"
exit 1
fi
}
configure_cloud_credentials() {
print_step "4" "Configure Cloud Provider Credentials"
if [[ "$PROVIDER" == "scaleway" ]]; then
print_info "You need Scaleway API credentials."
print_info "Get them from: https://console.scaleway.com/iam/api-keys"
echo ""
if [[ -z "${SCW_ACCESS_KEY:-}" ]]; then
SCW_ACCESS_KEY=$(ask_input "Scaleway Access Key")
export SCW_ACCESS_KEY
else
print_success "SCW_ACCESS_KEY already set: ${SCW_ACCESS_KEY:0:8}..."
fi
if [[ -z "${SCW_SECRET_KEY:-}" ]]; then
SCW_SECRET_KEY=$(ask_input "Scaleway Secret Key")
export SCW_SECRET_KEY
else
print_success "SCW_SECRET_KEY already set"
fi
echo ""
print_info "Project ID determines where resources are created."
print_info "Find it at: https://console.scaleway.com/project/settings"
print_info " (Click on the project you want, then Settings → Project ID)"
echo ""
if [[ -n "${SCW_DEFAULT_PROJECT_ID:-}" ]]; then
print_info "Current project ID: ${SCW_DEFAULT_PROJECT_ID}"
if ask_yes_no "Use this project?" "y"; then
print_success "Using existing project ID"
else
SCW_DEFAULT_PROJECT_ID=$(ask_input "Scaleway Project ID")
export SCW_DEFAULT_PROJECT_ID
fi
else
SCW_DEFAULT_PROJECT_ID=$(ask_input "Scaleway Project ID")
export SCW_DEFAULT_PROJECT_ID
fi
else # hetzner
print_info "You need a Hetzner Cloud API token."
print_info "Get it from: https://console.hetzner.cloud/ → Security → API Tokens"
echo ""
if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
HCLOUD_TOKEN=$(ask_input "Hetzner API Token")
export HCLOUD_TOKEN
else
print_success "HCLOUD_TOKEN already set"
fi
fi
print_success "Cloud credentials configured"
}
configure_domain() {
print_step "5" "Configure Domain"
print_info "Your Forgejo instance needs a domain name."
print_info "Example: git.yourdomain.com"
echo ""
DOMAIN=$(ask_input "Enter your domain" "git.example.com")
print_info "After deployment, you'll need to create DNS records:"
echo ""
echo " ${DOMAIN} IN A <server-ipv4>"
echo " ${DOMAIN} IN AAAA <server-ipv6>"
echo ""
# Create inventory file from example if it doesn't exist
local inventory_example="${ANSIBLE_DIR}/inventory/production/hosts.yml.example"
if [[ ! -f "$INVENTORY_FILE" ]] && [[ -f "$inventory_example" ]]; then
print_action "Creating inventory file from example..."
cp "$inventory_example" "$INVENTORY_FILE"
fi
# Update inventory file
if [[ -f "$INVENTORY_FILE" ]]; then
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s|forgejo_domain:.*|forgejo_domain: ${DOMAIN}|g" "$INVENTORY_FILE"
else
sed -i "s|forgejo_domain:.*|forgejo_domain: ${DOMAIN}|g" "$INVENTORY_FILE"
fi
print_success "Domain configured in inventory"
else
print_error "Could not find or create inventory file"
return 1
fi
export DOMAIN
}
create_infrastructure() {
print_step "6" "Create Infrastructure"
print_info "This will create the following resources on ${PROVIDER}:"
echo ""
if [[ "$PROVIDER" == "scaleway" ]]; then
echo " - DEV1-M compute instance"
echo " - 50GB block storage volume"
echo " - Security group"
echo " - Reserved IP address"
echo " - SSH key registration"
else
echo " - CPX21 compute instance"
echo " - Block volume"
echo " - Firewall rules"
echo " - SSH key registration"
fi
echo ""
if ! ask_yes_no "Create infrastructure now?"; then
print_warning "Skipping infrastructure creation"
return 0
fi
cd "$TERRAFORM_DIR/$PROVIDER/compute"
# Update terraform.tfvars with the SSH public key and domain
local tfvars_file="terraform.tfvars"
if [[ -n "${SSH_PUBLIC_KEY:-}" ]]; then
print_action "Configuring SSH key in Terraform..."
# Create or update terraform.tfvars with the SSH key
if [[ -f "$tfvars_file" ]]; then
# Remove existing ssh_public_key line if present
grep -v '^ssh_public_key' "$tfvars_file" >"${tfvars_file}.tmp" || true
mv "${tfvars_file}.tmp" "$tfvars_file"
fi
# Append SSH public key
echo "" >>"$tfvars_file"
echo "# SSH public key (added by setup wizard)" >>"$tfvars_file"
echo "ssh_public_key = \"${SSH_PUBLIC_KEY}\"" >>"$tfvars_file"
print_success "SSH public key configured"
else
print_warning "No SSH public key available - you may need to add it manually to your cloud provider"
fi
# Update domain in tfvars if set
if [[ -n "${DOMAIN:-}" ]]; then
if [[ -f "$tfvars_file" ]]; then
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s|^domain_name = .*|domain_name = \"${DOMAIN}\"|" "$tfvars_file"
else
sed -i "s|^domain_name = .*|domain_name = \"${DOMAIN}\"|" "$tfvars_file"
fi
fi
fi
# Initialize
run_command "Initializing Terraform" terragrunt init
# Plan
print_action "Planning infrastructure..."
terragrunt plan
echo ""
if ! ask_yes_no "Apply this plan?"; then
print_warning "Infrastructure creation cancelled"
return 1
fi
# Apply
run_command "Creating infrastructure" terragrunt apply -auto-approve
# Get outputs
echo ""
print_success "Infrastructure created!"
echo ""
SERVER_IP=$(terragrunt output -raw server_ip 2>/dev/null || terragrunt output -raw server_ipv4 2>/dev/null || echo "")
if [[ -n "$SERVER_IP" ]]; then
echo "${GREEN}${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}"
echo "${GREEN}${BOLD}│ Server IP: ${SERVER_IP} ${RESET}"
echo "${GREEN}${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}"
echo ""
# Update inventory
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s|ansible_host:.*|ansible_host: ${SERVER_IP}|g" "$INVENTORY_FILE"
else
sed -i "s|ansible_host:.*|ansible_host: ${SERVER_IP}|g" "$INVENTORY_FILE"
fi
print_success "Server IP updated in inventory"
export SERVER_IP
fi
echo ""
print_warning "IMPORTANT: Create DNS records now!"
echo ""
echo " ${DOMAIN} IN A ${SERVER_IP}"
echo ""
print_info "DNS propagation may take a few minutes."
press_enter
cd "$SCRIPT_DIR"
}
wait_for_server() {
print_step "7" "Waiting for Server"
print_info "Waiting for server to be ready..."
print_info "Using SSH key: ${SSH_KEY}"
local max_attempts=30
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
echo -n " Attempt ${attempt}/${max_attempts}: "
if ssh -i "$SSH_KEY" -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes "root@${SERVER_IP}" "echo 'Server ready'" 2>/dev/null; then
echo ""
print_success "Server is ready!"
return 0
fi
echo "waiting..."
sleep 10
((attempt++))
done
print_error "Server not reachable after ${max_attempts} attempts"
print_info "You may need to wait longer or check your cloud provider console"
if ask_yes_no "Continue anyway?"; then
return 0
fi
return 1
}
deploy_base() {
print_step "8" "Deploy Base Configuration"
print_info "This will deploy:"
echo " - System packages and configuration"
echo " - Docker"
echo " - PostgreSQL database"
echo " - Redis cache"
echo " - Forgejo application"
echo " - Caddy web server (HTTPS)"
echo " - Tailscale VPN"
echo ""
print_warning "Tailscale will be installed but NOT UFW yet!"
print_info "We'll configure UFW after you authenticate Tailscale."
echo ""
if ! ask_yes_no "Start deployment?"; then
return 1
fi
# Deploy everything except UFW (run from ansible dir to pick up ansible.cfg)
cd "$ANSIBLE_DIR"
print_action "Running Ansible deployment (without UFW)..."
ansible-playbook \
-i "inventory/production/hosts.yml" \
"playbooks/deploy.yml" \
--ask-vault-pass \
--skip-tags "ufw,firewall"
cd "$SCRIPT_DIR"
print_success "Base deployment completed!"
}
configure_tailscale() {
print_step "9" "Configure Tailscale VPN"
echo ""
echo "${YELLOW}${BOLD}╔═══════════════════════════════════════════════════════════════════════════╗${RESET}"
echo "${YELLOW}${BOLD}║ TAILSCALE AUTHENTICATION REQUIRED ║${RESET}"
echo "${YELLOW}${BOLD}╚═══════════════════════════════════════════════════════════════════════════╝${RESET}"
echo ""
print_info "Tailscale is installed but needs to be authenticated."
print_info "This connects your server to your private Tailscale network."
echo ""
print_warning "After this step, SSH will ONLY be accessible via Tailscale!"
echo ""
echo "You have two options:"
echo ""
echo " ${BOLD}Option 1: Interactive (recommended)${RESET}"
echo " 1. SSH into the server: ssh -i ${SSH_KEY} root@${SERVER_IP}"
echo " 2. Run: sudo tailscale up --ssh"
echo " 3. Open the URL shown in your browser to authenticate"
echo ""
echo " ${BOLD}Option 2: Auth Key (for automation)${RESET}"
echo " 1. Generate a key at: https://login.tailscale.com/admin/settings/keys"
echo " 2. SSH into the server: ssh -i ${SSH_KEY} root@${SERVER_IP}"
echo " 3. Run: sudo tailscale up --authkey=tskey-auth-XXXXX"
echo ""
if ask_yes_no "Open SSH session to configure Tailscale now?"; then
print_action "Opening SSH session..."
print_info "Run: sudo tailscale up --ssh"
print_info "Then exit the SSH session when done."
echo ""
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@${SERVER_IP}" || true
fi
echo ""
print_warning "VERIFY: Is Tailscale authenticated and connected?"
echo ""
if ! ask_yes_no "Have you successfully authenticated Tailscale?"; then
print_error "Tailscale must be authenticated before enabling UFW!"
print_info "Without Tailscale, you will be locked out of your server."
echo ""
if ! ask_yes_no "Try again?"; then
print_warning "Skipping UFW configuration. Your server SSH is still publicly accessible."
print_info "Run 'make deploy-tags TAGS=ufw' later after configuring Tailscale."
return 1
fi
configure_tailscale
return $?
fi
print_success "Tailscale configured!"
# Verify Tailscale connection
print_action "Verifying Tailscale connection..."
local tailscale_ip
tailscale_ip=$(ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@${SERVER_IP}" "tailscale ip -4" 2>/dev/null || echo "")
if [[ -n "$tailscale_ip" ]]; then
echo ""
echo "${GREEN}${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}"
echo "${GREEN}${BOLD}│ Tailscale IP: ${tailscale_ip} ${RESET}"
echo "${GREEN}${BOLD}│ │${RESET}"
echo "${GREEN}${BOLD}│ After UFW is enabled, use this IP for SSH: │${RESET}"
echo "${GREEN}${BOLD}│ ssh root@${tailscale_ip} ${RESET}"
echo "${GREEN}${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}"
echo ""
export TAILSCALE_IP="$tailscale_ip"
fi
return 0
}
enable_firewall() {
print_step "10" "Enable UFW Firewall"
print_warning "This will enable the UFW firewall with the following rules:"
echo ""
echo " ${GREEN}PUBLIC ACCESS:${RESET}"
echo " - Port 80 (HTTP) - Caddy/Let's Encrypt"
echo " - Port 443 (HTTPS) - Forgejo web interface"
echo ""
echo " ${YELLOW}TAILSCALE-ONLY ACCESS:${RESET}"
echo " - Port 22 (SSH) - System administration"
echo " - Port 2222 (Git SSH) - Git clone/push/pull"
echo " - All other internal services"
echo ""
print_warning "After this, public SSH will be blocked!"
print_info "You will need to use Tailscale to access SSH."
echo ""
if ! ask_yes_no "Enable UFW firewall now?"; then
print_warning "Skipping UFW. Your SSH is still publicly accessible."
print_info "Run 'make deploy-tags TAGS=ufw' later to enable UFW."
return 0
fi
# Run from ansible dir to pick up ansible.cfg
cd "$ANSIBLE_DIR"
print_action "Enabling UFW firewall..."
ansible-playbook \
-i "inventory/production/hosts.yml" \
"playbooks/deploy.yml" \
--ask-vault-pass \
--tags "ufw,firewall"
cd "$SCRIPT_DIR"
print_success "UFW firewall enabled!"
# Verify we can still connect via Tailscale
if [[ -n "${TAILSCALE_IP:-}" ]]; then
print_action "Verifying Tailscale SSH access..."
if ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o StrictHostKeyChecking=no "root@${TAILSCALE_IP}" "echo 'Tailscale SSH works!'" 2>/dev/null; then
print_success "Tailscale SSH access confirmed!"
else
print_warning "Could not verify Tailscale SSH. Please test manually."
fi
fi
}
show_completion() {
print_step "✓" "Setup Complete!"
echo ""
echo "${GREEN}${BOLD}╔═══════════════════════════════════════════════════════════════════════════╗${RESET}"
echo "${GREEN}${BOLD}║ ║${RESET}"
echo "${GREEN}${BOLD}║ 🎉 Congratulations! Your Forgejo instance is ready! ║${RESET}"
echo "${GREEN}${BOLD}║ ║${RESET}"
echo "${GREEN}${BOLD}╚═══════════════════════════════════════════════════════════════════════════╝${RESET}"
echo ""
echo "${CYAN}${BOLD}Access Your Forgejo:${RESET}"
echo ""
echo " Web Interface: https://${DOMAIN}"
echo " Admin Username: admin"
echo " Admin Password: (the one shown earlier)"
echo ""
echo "${CYAN}${BOLD}SSH Access (via Tailscale):${RESET}"
echo ""
if [[ -n "${TAILSCALE_IP:-}" ]]; then
echo " ssh -i ${SSH_KEY} root@${TAILSCALE_IP}"
else
echo " ssh -i ${SSH_KEY} root@<tailscale-ip>"
fi
echo ""
echo "${CYAN}${BOLD}Git Clone URLs:${RESET}"
echo ""
echo " HTTPS: https://${DOMAIN}/user/repo.git"
echo " SSH: git@<tailscale-hostname>:user/repo.git"
echo ""
echo "${CYAN}${BOLD}Useful Commands:${RESET}"
echo ""
echo " make status # Check service status"
echo " make logs # View Forgejo logs"
echo " make backup # Create backup"
echo " make update # Update Forgejo"
echo ""
echo "${CYAN}${BOLD}Documentation:${RESET}"
echo ""
echo " docs/CONFIGURATION.md - All configuration options"
echo " docs/OPERATIONS.md - Operations guide"
echo " README.md - Quick reference"
echo ""
echo "${YELLOW}${BOLD}Next Steps:${RESET}"
echo ""
echo " 1. Wait for DNS propagation (check: dig ${DOMAIN})"
echo " 2. Access https://${DOMAIN} in your browser"
echo " 3. Log in with admin credentials"
echo " 4. Configure 2FA for the admin account"
echo " 5. Create your first repository!"
echo ""
}
# ==============================================================================
# Main
# ==============================================================================
show_resume_status() {
echo ""
echo "${CYAN}${BOLD}Previous session detected. Completed steps:${RESET}"
echo ""
# Check each step individually (some use is_step_done, some use has_state)
# Steps that store "done": dependencies, secrets, cloud_credentials, infrastructure, server_ready, base_deploy, tailscale, firewall
# Steps that store values: provider, domain, server_ip
if is_step_done "dependencies"; then
echo " ${GREEN}${RESET} Check Dependencies"
else
echo " ${DIM}${RESET} Check Dependencies"
fi
if has_state "ssh_key"; then
echo " ${GREEN}${RESET} SSH Key ($(basename "$(get_state ssh_key)"))"
else
echo " ${DIM}${RESET} SSH Key"
fi
if has_state "provider"; then
echo " ${GREEN}${RESET} Choose Provider ($(get_state provider))"
else
echo " ${DIM}${RESET} Choose Provider"
fi
if is_step_done "secrets"; then
echo " ${GREEN}${RESET} Configure Secrets"
else
echo " ${DIM}${RESET} Configure Secrets"
fi
if is_step_done "cloud_credentials"; then
echo " ${GREEN}${RESET} Cloud Credentials"
else
echo " ${DIM}${RESET} Cloud Credentials"
fi
if has_state "domain"; then
echo " ${GREEN}${RESET} Configure Domain ($(get_state domain))"
else
echo " ${DIM}${RESET} Configure Domain"
fi
if is_step_done "infrastructure"; then
echo " ${GREEN}${RESET} Create Infrastructure"
else
echo " ${DIM}${RESET} Create Infrastructure"
fi
if is_step_done "server_ready"; then
echo " ${GREEN}${RESET} Wait for Server"
else
echo " ${DIM}${RESET} Wait for Server"
fi
if is_step_done "base_deploy"; then
echo " ${GREEN}${RESET} Base Deployment"
else
echo " ${DIM}${RESET} Base Deployment"
fi
if is_step_done "tailscale"; then
echo " ${GREEN}${RESET} Tailscale Setup"
else
echo " ${DIM}${RESET} Tailscale Setup"
fi
if is_step_done "firewall"; then
echo " ${GREEN}${RESET} Enable Firewall"
else
echo " ${DIM}${RESET} Enable Firewall"
fi
# Show server IP if set
local saved_ip
saved_ip=$(get_state "server_ip")
if [[ -n "$saved_ip" ]]; then
echo ""
echo "${CYAN}${BOLD}Server IP:${RESET} ${saved_ip}"
fi
echo ""
}
main() {
# Load previous state if exists
load_state
print_banner
# Check if resuming
if [[ -f "$STATE_FILE" ]]; then
show_resume_status
echo ""
local choice
choice=$(ask_choice "What would you like to do?" "Continue from where you left off" "Start fresh (reset all progress)")
if [[ "$choice" == "1" ]]; then
# Reset state
rm -f "$STATE_FILE"
print_info "Starting fresh..."
echo ""
else
# Restore saved values
PROVIDER=$(get_state "provider")
DOMAIN=$(get_state "domain")
SERVER_IP=$(get_state "server_ip")
TAILSCALE_IP=$(get_state "tailscale_ip")
SSH_KEY=$(get_state "ssh_key")
SSH_PUBLIC_KEY=$(get_state "ssh_public_key")
export PROVIDER DOMAIN SERVER_IP TAILSCALE_IP SSH_KEY SSH_PUBLIC_KEY
print_info "Resuming previous session..."
echo ""
fi
else
echo "This wizard will guide you through setting up your own Forgejo instance."
echo "It takes about 10-15 minutes to complete."
echo ""
if ! ask_yes_no "Ready to begin?"; then
echo "Setup cancelled."
exit 0
fi
fi
# Step 1: Dependencies
if ! is_step_done "dependencies"; then
check_dependencies
save_state "dependencies" "done"
press_enter
else
print_info "Skipping dependencies check (already completed)"
fi
# Step 1b: SSH Key selection (stores path, not "done")
if ! has_state "ssh_key"; then
select_ssh_key
save_state "ssh_key" "$SSH_KEY"
# Store public key content for terraform (base64 encoded to handle newlines/spaces)
if [[ -n "${SSH_PUBLIC_KEY:-}" ]]; then
save_state "ssh_public_key" "$SSH_PUBLIC_KEY"
fi
press_enter
else
SSH_KEY=$(get_state "ssh_key")
SSH_PUBLIC_KEY=$(get_state "ssh_public_key")
export SSH_KEY SSH_PUBLIC_KEY
print_info "Using SSH key: ${SSH_KEY}"
fi
# Step 2: Provider (stores actual value, not "done")
if ! has_state "provider"; then
choose_provider
save_state "provider" "$PROVIDER"
press_enter
else
print_info "Skipping provider selection (using: ${PROVIDER})"
fi
# Step 3: Secrets
if ! is_step_done "secrets"; then
configure_secrets
save_state "secrets" "done"
press_enter
else
print_info "Skipping secrets configuration (already completed)"
fi
# Step 4: Cloud Credentials
# Skip cloud credentials entirely if infrastructure is already created
# (remaining steps are Ansible-only and don't need cloud provider access)
if is_step_done "infrastructure"; then
print_info "Skipping cloud credentials (infrastructure already exists)"
elif ! is_step_done "cloud_credentials"; then
configure_cloud_credentials
save_state "cloud_credentials" "done"
press_enter
else
print_info "Skipping cloud credentials (already configured)"
# Re-prompt for credentials since they're not persisted (security)
configure_cloud_credentials
fi
# Step 5: Domain (stores actual value, not "done")
if ! has_state "domain"; then
configure_domain
save_state "domain" "$DOMAIN"
press_enter
else
print_info "Skipping domain configuration (using: ${DOMAIN})"
fi
# Step 6: Infrastructure
if ! is_step_done "infrastructure"; then
create_infrastructure
if [[ -n "${SERVER_IP:-}" ]]; then
save_state "infrastructure" "done"
save_state "server_ip" "$SERVER_IP"
fi
else
print_info "Skipping infrastructure creation (already created)"
fi
# Ensure we have server IP
if [[ -z "${SERVER_IP:-}" ]]; then
SERVER_IP=$(ask_input "Enter server IP address")
save_state "server_ip" "$SERVER_IP"
export SERVER_IP
fi
# Step 7: Wait for Server
if ! is_step_done "server_ready"; then
wait_for_server
save_state "server_ready" "done"
press_enter
else
print_info "Skipping server wait (already verified)"
fi
# Step 8: Base Deployment
if ! is_step_done "base_deploy"; then
deploy_base
save_state "base_deploy" "done"
press_enter
else
print_info "Skipping base deployment (already deployed)"
fi
# Step 9: Tailscale
if ! is_step_done "tailscale"; then
if configure_tailscale; then
save_state "tailscale" "done"
if [[ -n "${TAILSCALE_IP:-}" ]]; then
save_state "tailscale_ip" "$TAILSCALE_IP"
fi
fi
else
print_info "Skipping Tailscale configuration (already configured)"
fi
# Step 10: Firewall
if ! is_step_done "firewall"; then
if is_step_done "tailscale"; then
enable_firewall
save_state "firewall" "done"
else
print_warning "Skipping firewall - Tailscale must be configured first"
fi
else
print_info "Skipping firewall setup (already enabled)"
fi
show_completion
# Clean up state file on successful completion
if is_step_done "firewall" || is_step_done "tailscale"; then
if ask_yes_no "Setup complete! Remove progress tracking file?" "y"; then
rm -f "$STATE_FILE"
fi
fi
}
# Run main function
main "$@"