Installing MySQL 8 on CentOS Stream 9: Official Repository, SELinux, and Performance Tuning

CentOS tutorial - IT technology blog
CentOS tutorial - IT technology blog

The Real Problem: Installing MySQL 8 on CentOS Stream 9 is Full of Pitfalls

When CentOS 8 hit EOL in 2021, I had to rush-migrate 5 servers to Rocky Linux in a single week — and one of the most painful lessons was the database situation. Those servers were running MariaDB from the default repo, but the application was written specifically for MySQL 8. The result: two extra days of debugging just because stored procedure syntax differs between the two engines, JSON path expressions behaved unexpectedly, and a pile of edge cases nobody thought about before migrating.

That experience taught me a simple rule: when setting up MySQL 8 on CentOS Stream 9, do it right from the start. Install from the official Oracle repo, keep SELinux running instead of disabling it outright, control firewalld properly, and tune InnoDB to match your actual workload. This post documents exactly that workflow.

The Problem: Why dnf install mysql-server Is a Mistake

Running dnf install mysql-server on CentOS Stream 9 will not install MySQL — it installs MariaDB (a MySQL fork). The two engines differ in several important ways:

  • JSON support: MySQL 8 has native JSON with indexing via generated columns; MariaDB implements this differently
  • Window functions: MySQL 8 fully supports SQL:2003; MariaDB has some deviations from the standard
  • Default authentication: MySQL 8 uses caching_sha2_password instead of mysql_native_password. Older clients like Connector/J 5.1 or libmysqlclient < 8.0 will immediately throw connection errors — you must either configure this explicitly or upgrade the driver
  • InnoDB engine: MySQL 8 includes significant improvements in parallel read-ahead and adaptive hash indexing that MariaDB has not yet implemented

If your application is written specifically for MySQL, using MariaDB will create hard-to-trace bugs — especially with complex triggers and JSON operations.

Ways to Install MySQL 8 on CentOS Stream 9

Option 1: MySQL Community Repository (Recommended)

The official approach from Oracle — the repo always has the latest version, GPG keys are verified automatically, and updates work through the standard dnf update command.

Option 2: Manually Download RPM Packages

Suitable for air-gapped environments without internet access, but you must manually manage dependencies and versions — a real headache when security patches arrive.

Option 3: Compile from Source

Reserved for cases requiring special custom build flags (custom TLS library, non-standard paths, etc.). Unless you have a very specific technical reason, don’t do this on production.

The Right Way: Install from the MySQL Official Repository

Step 1: Add the MySQL Repository

Download the RPM package from the MySQL website — this package automatically adds the GPG key and configures all channels (community, tools, connectors):

# Download MySQL repo package for EL9 (CentOS Stream 9 / RHEL 9)
sudo rpm -Uvh https://dev.mysql.com/get/mysql84-community-release-el9-1.noarch.rpm

# Check if the repository is active
dnf repolist | grep mysql

If you want to install MySQL 8.0 instead of 8.4, switch the channel:

# Disable 8.4, enable 8.0
sudo dnf config-manager --disable mysql-8.4-lts-community
sudo dnf config-manager --enable mysql80-community

Step 2: Install MySQL Server

# Install MySQL Community Server
sudo dnf install mysql-community-server -y

# Start and enable the service
sudo systemctl start mysqld
sudo systemctl enable mysqld

# Check status
sudo systemctl status mysqld

Step 3: Get the Temporary Password and Run the Secure Script

MySQL 8 generates a random password on first startup — find it in the log:

sudo grep 'temporary password' /var/log/mysqld.log

Output looks like: A temporary password is generated for root@localhost: Xk9!mNpQ2#rL

Run the security script — on production, I never skip any steps:

sudo mysql_secure_installation
  • Validate password component: YES (choose STRONG level for production)
  • Remove anonymous users: YES
  • Disallow root login remotely: YES — critical, do not skip this
  • Remove test database: YES
  • Reload privilege tables: YES

Configuring SELinux for MySQL 8

This is the section most admins skip, solving it quickly with setenforce 0. Classic mistake. SELinux is not your enemy — it just needs the right context configured, which takes about 5 minutes.

Checking the SELinux Context

# MySQL process must run with the correct context
ps auxZ | grep mysqld
# Expect: system_u:system_r:mysqld_t:s0

# Data directory must have the correct context
ls -lZ /var/lib/mysql/
# Expect: system_u:object_r:mysqld_db_t:s0

Using a Custom Data Directory

When mounting data to a separate disk on production (very common when the OS and data volumes are separated), you need to set the SELinux context before MySQL starts:

# Create the new data directory
sudo mkdir -p /data/mysql
sudo chown -R mysql:mysql /data/mysql

# Set SELinux context
sudo semanage fcontext -a -t mysqld_db_t "/data/mysql(/.*)?" 
sudo restorecon -Rv /data/mysql

# Add to my.cnf
sudo bash -c 'echo "datadir=/data/mysql" >> /etc/my.cnf'

# Initialize the new data directory
sudo mysqld --initialize --user=mysql

Allowing MySQL Ports Through SELinux

# Verify port 3306 is already allowed
sudo semanage port -l | grep mysqld
# mysqld_port_t   tcp   1186, 3306, 63132-63164

# If using a different port (e.g., 3307)
sudo semanage port -a -t mysqld_port_t -p tcp 3307

Configuring firewalld for MySQL

Never open port 3306 to 0.0.0.0 on production — I once saw a server get continuously brute-force scanned because an admin opened MySQL to the internet for temporary debugging and forgot to close it.

Allowing Only Specific IPs to Connect

# Create a dedicated zone for database traffic (cleaner than adding rules to the default zone)
sudo firewall-cmd --new-zone=dbzone --permanent

# Allow the app server IP (replace 192.168.1.100 with the actual IP)
sudo firewall-cmd --zone=dbzone --add-source=192.168.1.100/32 --permanent
sudo firewall-cmd --zone=dbzone --add-port=3306/tcp --permanent

# Reload and verify
sudo firewall-cmd --reload
sudo firewall-cmd --zone=dbzone --list-all

Debugging Blocked MySQL Connections

# Which port is MySQL bound to?
sudo ss -tlnp | grep mysqld

# Test from the app server
mysql -h <db_ip> -u appuser -p -e "SELECT 1;"

# Check if firewalld is blocking packets
sudo journalctl -u firewalld -f

Optimizing MySQL 8 Performance on Production

MySQL 8 out of the box is calibrated for a machine with ~1GB of RAM. Running a production server with 8GB or 16GB on the default config is a waste of resources. These are the settings I tune first on any production server:

[mysqld]
# InnoDB Buffer Pool — most critical setting, set to 70-80% of total RAM
# 8GB RAM server -> 6G, 16GB server -> 12G
innodb_buffer_pool_size = 6G

# Increase pool instances to reduce mutex contention
# Rule: 1 instance per 1GB of buffer pool, max 64
innodb_buffer_pool_instances = 6

# Redo log capacity
# MySQL 8.0.x (before 8.0.30): use innodb_log_file_size = 512M
# MySQL 8.0.30+ / 8.4 LTS: old variable removed, use this parameter (= log_file_size x 2)
innodb_redo_log_capacity = 1G

# Max connections based on workload (don't set too high — each connection consumes RAM)
max_connections = 200

# Slow query log — always enable on production to catch slow queries
slow_query_log = 1
slow_query_log_file = /var/log/mysql-slow.log
long_query_time = 2

# Binary log for point-in-time recovery and replication
log_bin = /var/lib/mysql/mysql-bin
binlog_format = ROW
binlog_expire_logs_seconds = 604800

Restart MySQL after making changes and verify:

sudo systemctl restart mysqld

# Verify innodb_buffer_pool_size was applied correctly
mysql -u root -p -e "SHOW VARIABLES LIKE 'innodb_buffer_pool%';"

I usually run mysqltuner after 24 hours of real load — it analyzes based on actual traffic rather than estimates:

curl -L https://raw.githubusercontent.com/major/MySQLTuner-perl/master/mysqltuner.pl -o mysqltuner.pl
perl mysqltuner.pl --user root --pass <password>

Creating Database Users Following the Principle of Least Privilege

-- Never let the application connect using root
CREATE USER 'appuser'@'192.168.1.100' IDENTIFIED BY 'StrongP@ssword123!';

-- Grant only the necessary privileges on the specific database
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'appuser'@'192.168.1.100';

-- Verify the grants are set correctly
SHOW GRANTS FOR 'appuser'@'192.168.1.100';

Pre-Production Checklist

  • MySQL service is started and enabled to auto-start after reboot
  • mysql_secure_installation has been completed
  • Remote root login is blocked
  • SELinux context of the data directory is correct (mysqld_db_t)
  • firewalld only opens port 3306 for specific app server IPs
  • innodb_buffer_pool_size is set according to actual RAM (70-80%)
  • Slow query log is enabled
  • Binary log is enabled if point-in-time backup is needed
  • App user created with the minimum required privileges

Share: