#!/bin/bash # ============================================================================= # Mastodon Production Installer # ============================================================================= # Self-hosted Mastodon instance - production ready with Docker # # Supported: Ubuntu, Debian, Fedora, Rocky/Alma/RHEL 8+, Arch, openSUSE # Deploys via Docker Compose # # Usage: # curl -fsSL /install.sh | sudo bash # # Options: # --domain Your domain (required) # --email Admin email / Let's Encrypt # --no-ssl Skip SSL (local testing only) # --single-user Single user mode # --s3 Enable S3 storage configuration # ============================================================================= set -o pipefail # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' log() { echo -e "${BLUE}[INFO]${NC} $1"; } success() { echo -e "${GREEN}[OK]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; } # Configuration INSTALL_DIR="/opt/mastodon" DATA_DIR="/opt/mastodon-data" DOMAIN="" ADMIN_EMAIL="" ENABLE_SSL=true SINGLE_USER_MODE=false ENABLE_S3=false # Parse arguments while [ $# -gt 0 ]; do case $1 in --domain) DOMAIN="$2"; shift 2 ;; --email) ADMIN_EMAIL="$2"; shift 2 ;; --no-ssl) ENABLE_SSL=false; shift ;; --single-user) SINGLE_USER_MODE=true; shift ;; --s3) ENABLE_S3=true; shift ;; --help|-h) echo "Mastodon Production Installer" echo "" echo "Usage: install.sh [options]" echo "" echo "Options:" echo " --domain Your domain (e.g., mastodon.example.com)" echo " --email Admin email for Let's Encrypt" echo " --no-ssl Skip SSL (testing only)" echo " --single-user Single user mode" echo " --s3 Configure S3 storage" exit 0 ;; *) shift ;; esac done # Check root [ "$(id -u)" -ne 0 ] && error "Run as root: sudo bash install.sh" # Detect OS detect_os() { if [ -f /etc/os-release ]; then . /etc/os-release OS=$ID OS_VERSION=${VERSION_ID:-} else error "Cannot detect OS" fi log "Detected: $OS $OS_VERSION" } # Wait for package manager locks wait_for_lock() { case $OS in ubuntu|debian|linuxmint|pop) while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 2 done ;; esac } # Install Docker install_docker() { if command -v docker >/dev/null 2>&1; then success "Docker already installed" systemctl enable --now docker 2>/dev/null || true return fi log "Installing Docker..." case $OS in ubuntu|debian|linuxmint|pop) export DEBIAN_FRONTEND=noninteractive wait_for_lock apt-get update -qq apt-get install -y -qq ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings DOCKER_OS=$OS case "$OS" in linuxmint|pop) DOCKER_OS="ubuntu" ;; esac curl -fsSL https://download.docker.com/linux/$DOCKER_OS/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null chmod a+r /etc/apt/keyrings/docker.gpg CODENAME=${VERSION_CODENAME:-jammy} case "$OS" in linuxmint|pop) CODENAME="jammy" ;; esac [ "$OS" = "debian" ] && case "$CODENAME" in trixie|sid) CODENAME="bookworm" ;; esac echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$DOCKER_OS $CODENAME stable" > /etc/apt/sources.list.d/docker.list wait_for_lock apt-get update -qq apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ;; fedora) dnf install -y -q dnf-plugins-core dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo dnf install -y -q docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ;; rocky|almalinux|rhel|centos) dnf install -y -q dnf-plugins-core || yum install -y yum-utils dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || \ yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo dnf install -y -q docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || \ yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ;; arch|manjaro|endeavouros) pacman -Sy --noconfirm docker docker-compose ;; opensuse*|sles) zypper install -y docker docker-compose ;; *) error "Unsupported OS: $OS" ;; esac systemctl enable --now docker success "Docker installed" } # Generate secrets generate_secrets() { SECRET_KEY_BASE=$(openssl rand -hex 64) OTP_SECRET=$(openssl rand -hex 64) # Generate VAPID keys VAPID_KEYS=$(docker run --rm tootsuite/mastodon:latest bundle exec rake mastodon:webpush:generate_vapid_key 2>/dev/null || echo "") if [ -n "$VAPID_KEYS" ]; then VAPID_PRIVATE_KEY=$(echo "$VAPID_KEYS" | grep VAPID_PRIVATE_KEY | cut -d= -f2) VAPID_PUBLIC_KEY=$(echo "$VAPID_KEYS" | grep VAPID_PUBLIC_KEY | cut -d= -f2) else VAPID_PRIVATE_KEY=$(openssl rand -hex 32) VAPID_PUBLIC_KEY=$(openssl rand -hex 32) fi POSTGRES_PASSWORD="REDACTED_PASSWORD" rand -hex 32) REDIS_PASSWORD="REDACTED_PASSWORD" rand -hex 32) } # Get domain interactively get_domain() { if [ -z "$DOMAIN" ]; then echo "" echo "========================================" echo " Domain Configuration" echo "========================================" echo "" echo "Enter your domain for Mastodon (e.g., mastodon.example.com)" echo "A domain is REQUIRED for Mastodon to work properly." echo "" read -p "Domain: " DOMAIN if [ -z "$DOMAIN" ]; then error "Domain is required for Mastodon" fi fi if [ -z "$ADMIN_EMAIL" ]; then read -p "Admin email: " ADMIN_EMAIL if [ -z "$ADMIN_EMAIL" ]; then warn "No email provided - SSL may not work" ADMIN_EMAIL="admin@$DOMAIN" fi fi } # Create directories create_directories() { log "Creating directories..." mkdir -p "$INSTALL_DIR" mkdir -p "$DATA_DIR"/{postgres,redis,mastodon/{public/system,live}} mkdir -p "$DATA_DIR"/caddy/{data,config} chmod -R 755 "$DATA_DIR" success "Directories created" } # Create .env file create_env() { log "Creating environment configuration..." local protocol="https" [ "$ENABLE_SSL" != true ] && protocol="http" cat > "$INSTALL_DIR/.env.production" << EOF # Federation LOCAL_DOMAIN=$DOMAIN SINGLE_USER_MODE=$SINGLE_USER_MODE # Redis REDIS_HOST=redis REDIS_PORT=6379 REDIS_PASSWORD="REDACTED_PASSWORD" # PostgreSQL DB_HOST=db DB_USER=mastodon DB_NAME=mastodon DB_PASS="REDACTED_PASSWORD" DB_PORT=5432 # Secrets SECRET_KEY_BASE=$SECRET_KEY_BASE OTP_SECRET=$OTP_SECRET VAPID_PRIVATE_KEY=$VAPID_PRIVATE_KEY VAPID_PUBLIC_KEY=$VAPID_PUBLIC_KEY # Web WEB_DOMAIN=$DOMAIN ALTERNATE_DOMAINS= # Email (configure for production) SMTP_SERVER=smtp.mailgun.org SMTP_PORT=587 SMTP_LOGIN= SMTP_PASSWORD= "REDACTED_PASSWORD" SMTP_AUTH_METHOD=plain SMTP_OPENSSL_VERIFY_MODE=none SMTP_ENABLE_STARTTLS=auto # File storage # For S3 storage, uncomment and configure: # S3_ENABLED=true # S3_BUCKET=your-bucket # AWS_ACCESS_KEY_ID= # AWS_SECRET_ACCESS_KEY= # S3_REGION=us-east-1 # S3_PROTOCOL=https # S3_HOSTNAME=s3.amazonaws.com # Elasticsearch (optional, for full-text search) # ES_ENABLED=true # ES_HOST=elasticsearch # ES_PORT=9200 # Performance RAILS_ENV=production NODE_ENV=production RAILS_LOG_LEVEL=warn TRUSTED_PROXY_IP=172.16.0.0/12 # IP and session IP_RETENTION_PERIOD=31556952 SESSION_RETENTION_PERIOD=31556952 EOF chmod 600 "$INSTALL_DIR/.env.production" success "Environment configuration created" } # Create docker-compose.yml create_compose() { log "Creating Docker Compose file..." cat > "$INSTALL_DIR/docker-compose.yml" << 'EOF' services: db: image: postgres:16-alpine container_name: mastodon-db shm_size: 256mb environment: POSTGRES_USER: mastodon POSTGRES_PASSWORD: "REDACTED_PASSWORD" POSTGRES_DB: mastodon volumes: - ./data/postgres:/var/lib/postgresql/data restart: unless-stopped healthcheck: test: ["CMD", "pg_isready", "-U", "mastodon"] interval: 10s timeout: 5s retries: 5 networks: - internal redis: image: redis:7-alpine container_name: mastodon-redis command: redis-server --requirepass REDACTED_PASSWORD volumes: - ./data/redis:/data restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:"REDACTED_PASSWORD" "ping"] interval: 10s timeout: 5s retries: 5 networks: - internal web: image: tootsuite/mastodon:latest container_name: mastodon-web env_file: .env.production command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000" volumes: - ./data/mastodon/public/system:/mastodon/public/system depends_on: db: condition: service_healthy redis: condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD-SHELL", "wget -q --spider --proxy=off localhost:3000/health || exit 1"] interval: 30s timeout: 10s retries: 3 networks: - internal - external streaming: image: tootsuite/mastodon:latest container_name: mastodon-streaming env_file: .env.production command: node ./streaming depends_on: db: condition: service_healthy redis: condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD-SHELL", "wget -q --spider --proxy=off localhost:4000/api/v1/streaming/health || exit 1"] interval: 30s timeout: 10s retries: 3 networks: - internal - external sidekiq: image: tootsuite/mastodon:latest container_name: mastodon-sidekiq env_file: .env.production command: bundle exec sidekiq volumes: - ./data/mastodon/public/system:/mastodon/public/system depends_on: db: condition: service_healthy redis: condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD-SHELL", "ps aux | grep '[s]idekiq 6' || exit 1"] interval: 30s timeout: 10s retries: 3 networks: - internal - external caddy: image: caddy:2-alpine container_name: mastodon-caddy ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./data/caddy/data:/data - ./data/caddy/config:/config - ./data/mastodon/public:/mastodon/public:ro depends_on: - web - streaming restart: unless-stopped networks: - external watchtower: image: containrrr/watchtower:latest container_name: mastodon-watchtower environment: WATCHTOWER_CLEANUP: "true" WATCHTOWER_SCHEDULE: "0 0 4 * * *" WATCHTOWER_LABEL_ENABLE: "false" volumes: - /var/run/docker.sock:/var/run/docker.sock restart: unless-stopped networks: internal: internal: true external: EOF # Extract DB_PASS for compose echo "DB_PASS="REDACTED_PASSWORD" > "$INSTALL_DIR/.env" echo "REDIS_PASSWORD="REDACTED_PASSWORD" >> "$INSTALL_DIR/.env" success "Docker Compose file created" } # Create Caddyfile create_caddyfile() { log "Creating Caddy configuration..." if [ "$ENABLE_SSL" = true ]; then cat > "$INSTALL_DIR/Caddyfile" << EOF $DOMAIN { encode gzip handle_path /system/* { file_server { root /mastodon/public } } handle /api/v1/streaming/* { reverse_proxy streaming:4000 } handle /* { reverse_proxy web:3000 } header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Frame-Options "SAMEORIGIN" X-Content-Type-Options "nosniff" X-XSS-Protection "1; mode=block" Referrer-Policy "strict-origin-when-cross-origin" } log { output stdout } } EOF else cat > "$INSTALL_DIR/Caddyfile" << EOF :80 { encode gzip handle_path /system/* { file_server { root /mastodon/public } } handle /api/v1/streaming/* { reverse_proxy streaming:4000 } handle /* { reverse_proxy web:3000 } } EOF fi success "Caddy configuration created" } # Initialize database init_database() { log "Initializing database..." cd "$INSTALL_DIR" # Start database first docker compose up -d db redis sleep 10 # Run migrations docker compose run --rm web bundle exec rails db:setup SAFETY_ASSURED=1 2>/dev/null || \ docker compose run --rm web bundle exec rails db:migrate SAFETY_ASSURED=1 # Precompile assets docker compose run --rm web bundle exec rails assets:precompile success "Database initialized" } # Create management script create_management_script() { log "Creating management script..." cat > /usr/local/bin/mastodon << 'EOF' #!/bin/bash cd /opt/mastodon || exit 1 case "${1:-help}" in start) docker compose up -d ;; stop) docker compose down ;; restart) docker compose restart ${2:-} ;; status) docker compose ps ;; logs) docker compose logs -f ${2:-} ;; update) docker compose pull docker compose up -d docker compose run --rm web bundle exec rails db:migrate docker compose run --rm web bundle exec rails assets:precompile docker compose restart ;; edit) ${EDITOR:-nano} /opt/mastodon/.env.production ;; admin) if [ -z "$2" ]; then echo "Usage: mastodon admin " exit 1 fi docker compose run --rm web bin/tootctl accounts create "$2" --email "${3:-admin@localhost}" --confirmed --role Owner ;; reset-password) if [ -z "$2" ]; then echo "Usage: mastodon reset-password " exit 1 fi docker compose run --rm web bin/tootctl accounts modify "$2" --reset-password ;; tootctl) shift docker compose run --rm web bin/tootctl "$@" ;; console) docker compose run --rm web bin/rails console ;; shell) docker compose run --rm web /bin/bash ;; backup) timestamp=$(date +"%Y%m%d_%H%M%S") backup_dir="/opt/mastodon-data/backups" mkdir -p "$backup_dir" echo "Backing up database..." docker compose exec -T db pg_dump -U mastodon mastodon > "$backup_dir/mastodon_db_$timestamp.sql" echo "Backing up media..." tar -czf "$backup_dir/mastodon_media_$timestamp.tar.gz" -C /opt/mastodon-data mastodon/public/system echo "Backup complete: $backup_dir" ls -la "$backup_dir"/*$timestamp* ;; cleanup) echo "Cleaning up old media..." docker compose run --rm web bin/tootctl media remove --days=7 docker compose run --rm web bin/tootctl preview_cards remove --days=30 docker compose run --rm web bin/tootctl statuses remove --days=90 ;; *) echo "Mastodon Management" echo "" echo "Usage: mastodon " echo "" echo "Commands:" echo " start Start all services" echo " stop Stop all services" echo " restart [service] Restart services" echo " status Show status" echo " logs [service] View logs" echo " update Update and migrate" echo " edit Edit configuration" echo " admin Create admin user" echo " reset-password Reset user password" echo " tootctl Run tootctl command" echo " console Rails console" echo " shell Bash shell" echo " backup Backup database and media" echo " cleanup Clean old media/statuses" ;; esac EOF chmod +x /usr/local/bin/mastodon success "Management script created" } # Configure firewall configure_firewall() { log "Configuring firewall..." if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then firewall-cmd --permanent --add-service=http 2>/dev/null || true firewall-cmd --permanent --add-service=https 2>/dev/null || true firewall-cmd --reload 2>/dev/null || true success "Firewall configured (firewalld)" elif command -v ufw >/dev/null 2>&1 && ufw status | grep -q "active"; then ufw allow 80/tcp 2>/dev/null || true ufw allow 443/tcp 2>/dev/null || true success "Firewall configured (ufw)" else warn "No active firewall detected" fi } # Deploy deploy() { log "Deploying Mastodon..." cd "$INSTALL_DIR" # Copy data directory reference ln -sf "$DATA_DIR" "$INSTALL_DIR/data" 2>/dev/null || true mkdir -p "$INSTALL_DIR/data" ln -sf "$DATA_DIR/postgres" "$INSTALL_DIR/data/postgres" ln -sf "$DATA_DIR/redis" "$INSTALL_DIR/data/redis" ln -sf "$DATA_DIR/mastodon" "$INSTALL_DIR/data/mastodon" ln -sf "$DATA_DIR/caddy" "$INSTALL_DIR/data/caddy" docker compose pull # Initialize database init_database # Start all services docker compose up -d # Wait for services log "Waiting for services to start..." sleep 15 success "Mastodon deployed!" } # Show completion message show_complete() { local protocol="https" [ "$ENABLE_SSL" != true ] && protocol="http" echo "" echo "========================================" echo " Mastodon Installation Complete!" echo "========================================" echo "" echo "Access:" echo " Web Interface: ${protocol}://${DOMAIN}" echo "" echo "Create your admin account:" echo " mastodon admin yourusername your@email.com" echo "" echo "Then reset password to get initial password:" echo " mastodon reset-password yourusername" echo "" echo "Commands:" echo " mastodon status - Show service status" echo " mastodon logs - View logs" echo " mastodon update - Update Mastodon" echo " mastodon backup - Backup database" echo " mastodon cleanup - Clean old media" echo " mastodon tootctl - Run tootctl commands" echo "" echo "Config: $INSTALL_DIR/.env.production" echo "Data: $DATA_DIR" echo "" echo "⚠️ Configure email in .env.production for:" echo " - Email notifications" echo " - Password resets" echo " - Account confirmations" echo "" } # Main main() { echo "" echo "========================================" echo " Mastodon Production Installer" echo "========================================" echo "" detect_os get_domain generate_secrets install_docker create_directories create_env create_compose create_caddyfile create_management_script configure_firewall deploy show_complete } main "$@"