Last active
June 12, 2026 08:23
-
-
Save imannms000/69e6a8c4d19032a2ad7a529b3cdc3431 to your computer and use it in GitHub Desktop.
Production-Hardened MySQL 8.0 Installer for Ubuntu 20.04/22.04/24.04 WSL2 Compatible | Idempotent | SOC2/ISO27001-aligned
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| #=============================================================================== | |
| # Production-Hardened MySQL 8.0 Installer for Ubuntu 20.04/22.04/24.04 | |
| # Idempotent | Failsafe | SOC2/ISO27001-aligned | |
| #=============================================================================== | |
| set -euo pipefail | |
| IFS=$'\n\t' | |
| #------------------------------------------------------------------------------- | |
| # CONFIGURATION | |
| #------------------------------------------------------------------------------- | |
| readonly SCRIPT_NAME="$(basename "$0")" | |
| readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log" | |
| readonly BACKUP_DIR="/root/mysql-backup-$(date +%Y%m%d-%H%M%S)" | |
| readonly MYSQL_CONFIG_DIR="/etc/mysql/mysql.conf.d" | |
| readonly MYSQLD_CNF="${MYSQL_CONFIG_DIR}/99-production-hardening.cnf" | |
| # Minimum system requirements | |
| readonly MIN_RAM_MB=1024 | |
| readonly MIN_DISK_MB=2048 | |
| #------------------------------------------------------------------------------- | |
| # LOGGING | |
| #------------------------------------------------------------------------------- | |
| exec > >(tee -a "${LOG_FILE}") 2>&1 | |
| log() { | |
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | |
| } | |
| die() { | |
| log "❌ FATAL: $*" | |
| exit 1 | |
| } | |
| #------------------------------------------------------------------------------- | |
| # PREFLIGHT CHECKS | |
| #------------------------------------------------------------------------------- | |
| preflight() { | |
| log "🔍 Running pre-flight checks..." | |
| # Root check | |
| if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then | |
| die "This script must be run as root or with sudo." | |
| fi | |
| # OS check | |
| if ! grep -qE 'Ubuntu (20|22|24)\.04' /etc/os-release 2>/dev/null; then | |
| log "⚠️ Warning: This script is tested on Ubuntu 20.04/22.04/24.04 only." | |
| read -r -p "Continue anyway? [y/N]: " confirm | |
| [[ "$confirm" =~ ^[Yy]$ ]] || die "Aborted by user." | |
| fi | |
| # RAM check | |
| local avail_ram | |
| avail_ram=$(free -m | awk '/^Mem:/{print $7}') | |
| if [[ "${avail_ram}" -lt "${MIN_RAM_MB}" ]]; then | |
| die "Insufficient available RAM: ${avail_ram}MB (min: ${MIN_RAM_MB}MB)" | |
| fi | |
| # Disk check | |
| local avail_disk | |
| avail_disk=$(df -m / | awk 'NR==2 {print $4}') | |
| if [[ "${avail_disk}" -lt "${MIN_DISK_MB}" ]]; then | |
| die "Insufficient disk space on /: ${avail_disk}MB (min: ${MIN_DISK_MB}MB)" | |
| fi | |
| # Check for MariaDB conflict | |
| if dpkg -l | grep -qE '^ii\s+mariadb'; then | |
| die "MariaDB is installed. MySQL and MariaDB conflict. Remove MariaDB first." | |
| fi | |
| log "✅ Pre-flight checks passed." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # PASSWORD INPUT WITH STRENGTH VALIDATION | |
| #------------------------------------------------------------------------------- | |
| prompt_password() { | |
| local var_name="$1" | |
| local prompt_text="$2" | |
| local confirm_text="$3" | |
| local min_length="${4:-16}" | |
| local pass1 pass2 | |
| while true; do | |
| read -r -s -p "${prompt_text}: " pass1 | |
| echo "" | |
| read -r -s -p "${confirm_text}: " pass2 | |
| echo "" | |
| if [[ -z "$pass1" ]]; then | |
| log "⚠️ Password cannot be empty." | |
| continue | |
| fi | |
| if [[ "${#pass1}" -lt "${min_length}" ]]; then | |
| log "⚠️ Password must be at least ${min_length} characters." | |
| continue | |
| fi | |
| if [[ "$pass1" != "$pass2" ]]; then | |
| log "❌ Passwords do not match." | |
| continue | |
| fi | |
| # Basic entropy check | |
| if ! [[ "$pass1" =~ [A-Z] && "$pass1" =~ [a-z] && "$pass1" =~ [0-9] && "$pass1" =~ [[:punct:]] ]]; then | |
| log "⚠️ Password must contain uppercase, lowercase, digit, and special character." | |
| continue | |
| fi | |
| printf -v "$var_name" '%s' "$pass1" | |
| break | |
| done | |
| } | |
| #------------------------------------------------------------------------------- | |
| # INSTALLATION (Idempotent: Skips if package is installed) | |
| #------------------------------------------------------------------------------- | |
| install_mysql() { | |
| if dpkg -l | grep -qE '^ii\s+mysql-server'; then | |
| log "📦 MySQL server package is already installed. Skipping installation step." | |
| return 0 | |
| fi | |
| log "🛠️ Installing MySQL Server..." | |
| export DEBIAN_FRONTEND=noninteractive | |
| apt-get update -y | |
| local mysql_version | |
| mysql_version=$(apt-cache policy mysql-server 2>/dev/null | grep Candidate | awk '{print $2}' | cut -d'-' -f1 | cut -d':' -f2 | cut -d'.' -f1-2 || echo "0") | |
| if [[ "$mysql_version" == "8.0" ]]; then | |
| log "📦 Using distribution-provided MySQL 8.0..." | |
| apt-get install -y mysql-server mysql-client | |
| else | |
| log "📥 Adding official MySQL APT repository..." | |
| local apt_config_url | |
| apt_config_url=$(curl -s https://dev.mysql.com/downloads/repo/apt/ | grep -oP 'https://dev.mysql.com/get/mysql-apt-config_[^"]+_all.deb' | head -1) | |
| if [[ -z "$apt_config_url" ]]; then | |
| die "Could not determine latest mysql-apt-config URL." | |
| fi | |
| local deb_file="/tmp/$(basename "$apt_config_url")" | |
| wget -q "$apt_config_url" -O "$deb_file" || die "Failed to download MySQL APT config" | |
| echo "mysql-apt-config mysql-apt-config/select-server select mysql-8.0" | debconf-set-selections | |
| dpkg -i "$deb_file" || apt-get install -f -y | |
| rm -f "$deb_file" | |
| apt-get update -y | |
| apt-get install -y mysql-server mysql-client | |
| fi | |
| systemctl enable mysql | |
| systemctl start mysql | |
| local wait_count=0 | |
| until mysqladmin ping --silent 2>/dev/null || [[ $wait_count -ge 30 ]]; do | |
| sleep 2 | |
| ((wait_count++)) | |
| done | |
| if ! mysqladmin ping --silent 2>/dev/null; then | |
| die "MySQL failed to start within 60 seconds. Check /var/log/mysql/error.log" | |
| fi | |
| log "✅ MySQL installed and running." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # SECURITY HARDENING (Idempotent: Uses conditional checks per command) | |
| #------------------------------------------------------------------------------- | |
| harden_mysql() { | |
| log "🔒 Applying security hardening..." | |
| local root_pass="$1" | |
| local admin_user="$2" | |
| local admin_pass="$3" | |
| local sql_file | |
| sql_file=$(mktemp /tmp/mysql-harden-XXXXXX.sql) | |
| chmod 600 "$sql_file" | |
| cat > "$sql_file" <<EOF | |
| -- Safely alter root auth if not already done | |
| ALTER USER IF EXISTS 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY '${root_pass}'; | |
| -- Create dedicated admin user if missing, or update its credentials safely | |
| CREATE USER IF NOT EXISTS '${admin_user}'@'localhost' IDENTIFIED WITH caching_sha2_password BY '${admin_pass}'; | |
| ALTER USER '${admin_user}'@'localhost' IDENTIFIED WITH caching_sha2_password BY '${admin_pass}'; | |
| GRANT ALL PRIVILEGES ON *.* TO '${admin_user}'@'localhost' WITH GRANT OPTION; | |
| -- Remove anonymous users | |
| DELETE FROM mysql.user WHERE User=''; | |
| -- Remove test database | |
| DROP DATABASE IF EXISTS test; | |
| DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%'; | |
| -- Remove remote root access | |
| DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1'); | |
| -- Swap root to auth_socket for local system administration convenience & security | |
| ALTER USER IF EXISTS 'root'@'localhost' IDENTIFIED WITH auth_socket; | |
| ALTER USER IF EXISTS '${admin_user}'@'localhost' PASSWORD EXPIRE NEVER; | |
| FLUSH PRIVILEGES; | |
| EOF | |
| # Execute SQL depending on current root authentication status | |
| if mysql -e "SELECT 1" &>/dev/null; then | |
| mysql < "$sql_file" | |
| else | |
| # Fallback if execution requires systemic root authentication pass | |
| mysql -u root -p"${root_pass}" --batch < "$sql_file" 2>/dev/null || mysql -u root --batch < "$sql_file" | |
| fi | |
| rm -f "$sql_file" | |
| # Fixed: Load timezone data natively using host zoneinfo maps safely | |
| log "🌍 Verifying system timezone tables inside MySQL..." | |
| mysql_tzinfo_to_sql /usr/share/zoneinfo 2>/dev/null | mysql -u root mysql || log "⚠️ Timezone population skipped or already processed." | |
| # Store credentials securely using mysql_config_editor | |
| local login_path="production_admin" | |
| mysql_config_editor remove --login-path="${login_path}" 2>/dev/null || true | |
| mysql_config_editor set --login-path="${login_path}" --host=localhost --user="${admin_user}" --password <<< "${admin_pass}" | |
| log "✅ Security hardening complete." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # PRODUCTION CONFIGURATION | |
| #------------------------------------------------------------------------------- | |
| configure_production() { | |
| log "⚙️ Applying production configuration..." | |
| mkdir -p "${MYSQL_CONFIG_DIR}" | |
| cat > "${MYSQLD_CNF}" <<'EOF' | |
| [mysqld] | |
| # Network Security | |
| bind-address = 127.0.0.1 | |
| mysqlx-bind-address = 127.0.0.1 | |
| skip-symbolic-links | |
| local_infile = 0 | |
| # Authentication | |
| default_authentication_plugin = caching_sha2_password | |
| # Logging (for audit and debugging) | |
| log_error = /var/log/mysql/error.log | |
| general_log = OFF | |
| slow_query_log = ON | |
| slow_query_log_file = /var/log/mysql/slow.log | |
| long_query_time = 2 | |
| # Binary logging (required for replication and point-in-time recovery) | |
| server-id = 1 | |
| log_bin = /var/log/mysql/mysql-bin | |
| binlog_expire_logs_seconds = 604800 # 7 days | |
| max_binlog_size = 100M | |
| # InnoDB Tuning (conservative defaults, adjust for your RAM) | |
| innodb_buffer_pool_size = 256M | |
| innodb_log_file_size = 64M | |
| innodb_flush_log_at_trx_commit = 2 | |
| innodb_flush_method = O_DIRECT | |
| # Connection limits | |
| max_connections = 151 | |
| wait_timeout = 28800 | |
| interactive_timeout = 28800 | |
| # Charset | |
| character-set-server = utf8mb4 | |
| collation-server = utf8mb4_0900_ai_ci | |
| # Security | |
| require_secure_transport = OFF # Set ON after SSL certs are configured | |
| EOF | |
| mkdir -p /var/log/mysql | |
| chown mysql:mysql /var/log/mysql | |
| chmod 750 /var/log/mysql | |
| chmod 644 "${MYSQLD_CNF}" | |
| # Restart gracefully to implement rules | |
| systemctl restart mysql | |
| local wait_count=0 | |
| until mysqladmin ping --silent 2>/dev/null || [[ $wait_count -ge 30 ]]; do | |
| sleep 2 | |
| ((wait_count++)) | |
| done | |
| if ! mysqladmin ping --silent 2>/dev/null; then | |
| die "MySQL failed to restart after configuration changes." | |
| fi | |
| log "✅ Production configuration applied." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # SYSTEM HARDENING | |
| #------------------------------------------------------------------------------- | |
| harden_system() { | |
| log "🛡️ Applying system-level hardening..." | |
| if ufw status | grep -q "Status: active"; then | |
| ufw deny 3306/tcp || true | |
| ufw allow from 127.0.0.1 to any port 3306 proto tcp || true | |
| log "✅ UFW rule verified: 3306 restricted to localhost." | |
| fi | |
| cat > /etc/logrotate.d/mysql-custom <<'EOF' | |
| /var/log/mysql/*.log { | |
| daily | |
| rotate 14 | |
| compress | |
| missingok | |
| notifempty | |
| create 640 mysql adm | |
| sharedscripts | |
| postrotate | |
| test -x /usr/bin/mysqladmin || exit 0 | |
| if /usr/bin/mysqladmin ping > /dev/null 2>&1; then | |
| /usr/bin/mysqladmin flush-logs | |
| fi | |
| endscript | |
| } | |
| EOF | |
| if [[ -f /etc/apt/apt.conf.d/50unattended-upgrades ]]; then | |
| if ! grep -q "mysql" /etc/apt/apt.conf.d/50unattended-upgrades; then | |
| sed -i 's/Unattended-Upgrade::Allowed-Origins {/Unattended-Upgrade::Allowed-Origins {\n\t\t"LP-PPA-mysql-8.0:${distro_codename}";\n\t\t"Ubuntu:${distro_codename}-security";/' /etc/apt/apt.conf.d/50unattended-upgrades || true | |
| fi | |
| fi | |
| chmod 750 /var/lib/mysql | |
| chown -R mysql:mysql /var/lib/mysql | |
| log "✅ System hardening complete." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # POST-INSTALL VERIFICATION | |
| #------------------------------------------------------------------------------- | |
| verify_installation() { | |
| log "🔍 Running post-install verification..." | |
| local admin_user="$1" | |
| systemctl is-active --quiet mysql || die "MySQL service is not active." | |
| ss -tlnp | grep -q "127.0.0.1:3306" || die "MySQL not bound to 127.0.0.1:3306" | |
| local root_auth | |
| root_auth=$(mysql -u root -e "SELECT plugin FROM mysql.user WHERE user='root' AND host='localhost';" 2>/dev/null | tail -1 || true) | |
| if [[ "$root_auth" != "auth_socket" ]]; then | |
| log "⚠️ Warning: root@localhost is not using auth_socket." | |
| fi | |
| local admin_count | |
| admin_count=$(mysql -u root -e "SELECT COUNT(*) FROM mysql.user WHERE user='${admin_user}' AND host='localhost';" 2>/dev/null | tail -1 || true) | |
| if [[ "$admin_count" != "1" ]]; then | |
| die "Admin user '${admin_user}' was not created successfully." | |
| fi | |
| local anon_count | |
| anon_count=$(mysql -u root -e "SELECT COUNT(*) FROM mysql.user WHERE user='';" 2>/dev/null | tail -1 || true) | |
| if [[ "$anon_count" != "0" ]]; then | |
| die "Anonymous users still exist." | |
| fi | |
| log "✅ All verification checks passed." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # MAIN | |
| #------------------------------------------------------------------------------- | |
| main() { | |
| log "====================================================" | |
| log " 🛡️ Production MySQL 8.0 Installer (Idempotent)" | |
| log "====================================================" | |
| preflight | |
| echo "" | |
| log "🔐 Configure MySQL root password..." | |
| prompt_password MY_ROOT_PASS "Enter strong root password" "Confirm root password" 16 | |
| echo "" | |
| log "👤 Configure dedicated Admin User..." | |
| while true; do | |
| read -r -p "Enter admin username [admin]: " ADMIN_USER | |
| ADMIN_USER=${ADMIN_USER:-admin} | |
| if [[ -z "$ADMIN_USER" || "$ADMIN_USER" == "root" ]]; then | |
| log "⚠️ Invalid username. Cannot be empty or 'root'." | |
| elif [[ "$ADMIN_USER" =~ [^a-zA-Z0-9_] ]]; then | |
| log "⚠️ Username must contain only letters, numbers, and underscores." | |
| else | |
| break | |
| fi | |
| done | |
| prompt_password ADMIN_PASS "Enter admin password" "Confirm admin password" 16 | |
| if [[ "$MY_ROOT_PASS" == "$ADMIN_PASS" ]]; then | |
| die "Root and admin passwords must be different." | |
| fi | |
| install_mysql | |
| harden_mysql "$MY_ROOT_PASS" "$ADMIN_USER" "$ADMIN_PASS" | |
| configure_production | |
| harden_system | |
| verify_installation "$ADMIN_USER" | |
| MY_ROOT_PASS="CLEARED" | |
| ADMIN_PASS="CLEARED" | |
| unset MY_ROOT_PASS ADMIN_PASS | |
| log "" | |
| log "====================================================" | |
| log "🎉 MySQL Configuration Maintained/Deployed successfully!" | |
| log "" | |
| log "🔐 ROOT ACCESS:" | |
| log " sudo mysql # OS auth only" | |
| log " (Network root auth is DISABLED)" | |
| log "" | |
| log "👤 ADMIN ACCESS:" | |
| log " mysql -u ${ADMIN_USER} -p" | |
| log "" | |
| log "📁 LOGS:" | |
| log " ${LOG_FILE}" | |
| log " /var/log/mysql/error.log" | |
| log "" | |
| log "⚠️ NEXT STEPS:" | |
| log " 1. Configure SSL: set require_secure_transport=ON after placing certs" | |
| log " 2. Adjust innodb_buffer_pool_size to 70% of RAM in:" | |
| log " ${MYSQLD_CNF}" | |
| log " 3. Set up automated backups (e.g., Percona XtraBackup)" | |
| log " 4. Configure monitoring (Prometheus mysqld_exporter / Datadog)" | |
| log "====================================================" | |
| } | |
| main "$@" |
Author
Author
// secured for documentation
Author
// secured for documentation
Author
// secured for documentation
Author
// secured for documentation
Author
// secured for documentation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Connecting Securely via DBeaver
1. Local
MySQL 8.0 uses a highly secure authentication plugin (
caching_sha2_password) by default, which is exactly what your hardening script configured.When you try to connect via DBeaver locally, MySQL requires an encrypted password transfer. If DBeaver doesn't have the server's public key cached locally yet, it tries to fetch it from the server. Because your script locked down connections, MySQL aggressively blocks this raw public key request for security, resulting in the "Public Key Retrieval is not allowed" error.
Fortunately, you don't need to change anything on your server to fix this. You just need to flip one toggle inside DBeaver.
How to Fix It in DBeaver
Assuming you already created MySQL connection on DBeaver.
allowPublicKeyRetrieval.false) and change it totrue.useSSLand ensure it is set tofalse(since your script currently hasrequire_secure_transport = OFFuntil you install real SSL certificates).2. Remote
Because your database is locked tightly to
127.0.0.1and protected by a local firewall rule, you cannot point DBeaver directly at your server's public IP address. Instead, you must bridge into the server securely using an SSH Tunnel.Connection Settings Matrix
Create a new MySQL connection profile inside DBeaver and complete the following two configuration tabs:
1. Main Connection Settings Tab
localhostlocalhostbecause DBeaver will evaluate this path from inside the server after tunneling.3306mysqladmin••••••••••••2. SSH Connection Tab
Check the box labeled "Use SSH Tunnel" and fill out your host environment properties:
Key Pair(Recommended) orPassword22(Or your custom SSH server port).rootorubuntu).Click Test Connection at the bottom of the DBeaver configuration prompt. The terminal interface will securely piggyback across your encrypted SSH boundary and initialize your database session safely.