MySQL and MariaDB both ship with a configuration designed to start on almost anything, including a laptop from 2009. The default innodb_buffer_pool_size is 128 MB. That single number means a database on a 2 GB VPS is using a sixteenth of the RAM it could be, reading pages from disk that should be sitting in memory. The result is a database that feels sluggish on a workload that should fit comfortably in cache.
The good news is that you do not need a DBA to fix this. A handful of well-chosen settings get you most of the way, and the rest is observability. This walkthrough covers what to change, why, how to apply it safely, and how to find the slow queries that no amount of tuning will save you from. MariaDB is a fork of MySQL, so the knobs are almost identical - the few places they differ are flagged as we go.
innodb_buffer_pool_size to 60-70% of RAM on a dedicated DB box, or 25-40% if the app shares the machine.innodb_flush_log_at_trx_commit = 2 for a big write speedup if you can tolerate losing ~1 second of data on a power loss.innodb_flush_method = O_DIRECT and, on NVMe, raise innodb_io_capacity.max_connections modest and the per-connection buffers small - they multiply.mysqltuner after a day of real traffic.Total time: around 30 minutes, plus one restart.
free -h will tell you.We assume Ubuntu 22.04 or 24.04. Only the file paths are distro-specific; the settings themselves are the same everywhere.
MySQL and MariaDB read a chain of files. Do not edit the packaged ones - drop your own file in so a package upgrade never clobbers your work. The include directory depends on the distro and the engine:
/etc/mysql/mariadb.conf.d//etc/mysql/mysql.conf.d//etc/my.cnf.d/If you are not sure what your server reads, ask it for the search order:
mysqld --verbose --help 2>/dev/null | grep -A1 "Default options"
Create a dedicated drop-in. The 99- prefix makes it sort last, so it wins any conflict:
# MariaDB
sudo nano /etc/mysql/mariadb.conf.d/99-tuning.cnf
# MySQL
sudo nano /etc/mysql/mysql.conf.d/99-tuning.cnf
Everything below goes under a [mysqld] header in that file.
SELECT VERSION();
Then confirm your tables are actually InnoDB. Everything in this guide tunes InnoDB; it does nothing for the old MyISAM engine, which uses key_buffer_size instead.
SELECT table_schema, engine, COUNT(*)
FROM information_schema.tables
WHERE table_schema NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')
GROUP BY table_schema, engine;
If you see MyISAM on real application tables, plan a migration to InnoDB (covered in Going Further). InnoDB has been the default since MySQL 5.5, so most databases are already there.
This is 80% of the win. The buffer pool caches table and index pages in RAM, and the default 128 MB is the first thing to fix.
[mysqld]
innodb_buffer_pool_size = 1G
How to size it:
On a 2 GB VPS running both an app and the database, 512M is a sane start. On a 4 GB database-only box, 2560M (around 62%) is reasonable. One caveat: do not exceed your data size. If the whole database is 400 MB, a 4 GB buffer pool just wastes RAM - size it to the data plus headroom for growth.
You can skip innodb_buffer_pool_instances. Splitting the pool only reduces contention above ~1 GB, and MariaDB 10.5+ ignores the setting entirely. On a small VPS, leave it at the default.
The buffer pool is resizable online (since MySQL 5.7 and MariaDB 10.2), so you can test a value before committing to a restart:
SET GLOBAL innodb_buffer_pool_size = 1073741824; -- 1 GiB, in bytes
SHOW STATUS LIKE 'Innodb_buffer_pool_resize_status';
Once the resize finishes, write the value into your .cnf so it survives a restart.
The redo log absorbs writes before they are checkpointed into the tablespace. The default is small, which forces frequent checkpoints and caps write throughput. MariaDB and recent MySQL name this setting differently:
# MariaDB 10.5+ (single redo log)
innodb_log_file_size = 512M
# MySQL 8.0.30+
innodb_redo_log_capacity = 536870912 # 512 MiB in bytes
A larger redo log means fewer checkpoint flushes and smoother writes; the cost is a slightly longer crash recovery. 256-512 MB is a good range for a small box.
Then the durability dial, the biggest single write speedup here:
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
innodb_flush_log_at_trx_commit controls how aggressively each commit is persisted. 1 (the default) is full ACID: flush and fsync on every commit. 2 flushes to the OS on every commit but only fsyncs about once a second - far less disk pressure, at the cost of losing up to a second of committed transactions if the whole machine loses power. 0 is similar but loses that second even on a plain mysqld crash. For anything that is not a payment ledger, 2 is the right trade.innodb_flush_method = O_DIRECT stops InnoDB pages being cached twice (once in the buffer pool, once in the OS page cache). On Linux with a normal filesystem this is almost always correct.The I/O defaults assume a single slow spinning disk. Any modern VPS is on NVMe, so raise the ceiling:
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000
innodb_flush_neighbors = 0
innodb_io_capacity is the steady-state budget for background flushing. The default 200 suits one rotational disk; NVMe handles thousands.innodb_flush_neighbors = 0 disables the optimisation that flushes adjacent pages together. That batching only helps on rotational disks where seeking is expensive. On flash it just writes pages you did not need to.The default max_connections = 151 is rarely right for a small box. Each connection costs memory before it runs a single query, and several buffers are allocated per connection.
max_connections = 80
thread_cache_size = 16
Then the dangerous ones - keep them small:
sort_buffer_size = 2M
join_buffer_size = 2M
read_buffer_size = 1M
read_rnd_buffer_size = 1M
These are allocated per connection, and sort_buffer_size can be allocated more than once inside a single complex query. Raising them globally to "go faster" is exactly how a box with plenty of idle RAM suddenly OOMs under load. The worst-case memory is roughly:
innodb_buffer_pool_size
+ max_connections * (sort_buffer_size + join_buffer_size
+ read_buffer_size + read_rnd_buffer_size + thread_stack)
If one report genuinely needs a big sort, raise the buffer for that session only, never globally:
SET SESSION sort_buffer_size = 16 * 1024 * 1024;
This one is a "do not". The old MySQL query cache was removed entirely in MySQL 8.0. MariaDB still has it, but it is off by default and should stay that way on any write-heavy or concurrent workload - its single global mutex becomes a bottleneck that is usually slower than no cache at all.
# MariaDB only; this is already the default - do not turn it on
query_cache_type = 0
query_cache_size = 0
If you want a query cache, put it in your application (Redis, or your framework's cache layer), not in the database.
Queries that group, sort, or use UNION may build temporary tables. If they outgrow the in-memory limit they spill to disk, which is slow.
tmp_table_size = 64M
max_heap_table_size = 64M
table_open_cache = 4000
open_files_limit = 8192
tmp_table_size and max_heap_table_size must be raised together - the effective limit is the lower of the two. Watch Created_tmp_disk_tables against Created_tmp_tables to see whether you are spilling to disk.
Save the drop-in, then restart:
sudo systemctl restart mariadb # MariaDB
sudo systemctl restart mysql # MySQL
Many of these settings are dynamic, so you can apply them at runtime to test before persisting. Set the live value, confirm it, then write it into your .cnf:
SET GLOBAL innodb_flush_log_at_trx_commit = 2;
SET GLOBAL max_connections = 80;
On MySQL 8 you can make a runtime change durable in one step - it writes to mysqld-auto.cnf:
SET PERSIST innodb_io_capacity = 2000;
MariaDB has no SET PERSIST; use SET GLOBAL for the live value and edit your .cnf for the permanent one. The buffer pool, IO capacity, flush-at-commit, and connection limits are all dynamic. The redo log size and innodb_flush_method are applied on restart.
Tuning settings is half the job. The other half is finding the queries that are slow.
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 0
long_query_time = 1 logs anything over a second. Turn log_queries_not_using_indexes on only briefly - on a busy server it floods the log. Make sure the file is writable by the server:
sudo touch /var/log/mysql/slow.log
sudo chown mysql:mysql /var/log/mysql/slow.log
You can enable it without a restart:
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1;
After a day of real traffic, find the worst offenders. mysqldumpslow ships with the server:
sudo mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
For a far better report, Percona's pt-query-digest groups queries by fingerprint and shows where the time actually goes:
sudo apt install -y percona-toolkit
sudo pt-query-digest /var/log/mysql/slow.log | less
Once you have a suspect query, see how the engine runs it:
EXPLAIN
SELECT u.id, u.email, COUNT(o.id) AS orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > NOW() - INTERVAL 30 DAY
GROUP BY u.id, u.email
ORDER BY orders DESC
LIMIT 50;
Sample output (trimmed):
+----+-------+------+---------------+------+-------+----------------------------------+
| id | table | type | possible_keys | key | rows | Extra |
+----+-------+------+---------------+------+-------+----------------------------------+
| 1 | u | ALL | NULL | NULL | 48211 | Using where; Using temporary; |
| | | | | | | Using filesort |
| 1 | o | ref | user_id_idx | ... | 4 | |
+----+-------+------+---------------+------+-------+----------------------------------+
Things to look for:
type: ALL is a full table scan. On a large table that wants a filter, add the missing index - here, on users.created_at.Using filesort and Using temporary mean the sort or grouping could not use an index. Often a single composite index removes both.rows estimate wildly off the real count means stale statistics. Run ANALYZE TABLE users;.For the actual execution rather than an estimate, MySQL 8.0.18+ has EXPLAIN ANALYZE, and MariaDB has the ANALYZE statement plus ANALYZE FORMAT=JSON.
After the server has been up under real load for a day, run MySQLTuner. It reads your live status counters and flags what is still wrong:
wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/mysqltuner.pl
sudo perl mysqltuner.pl
Pay attention to its "Maximum possible memory usage" line - that is the OOM ceiling from Step 6. If it exceeds your RAM, lower max_connections or the per-connection buffers.
The single most useful health number is the buffer pool hit ratio:
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
Divide Innodb_buffer_pool_reads (pages fetched from disk) by Innodb_buffer_pool_read_requests (total page reads). A healthy small box reads from disk well under 1% of the time. If that ratio stays high after the cache has warmed, the buffer pool is too small for your working set.
MySQL will not start after the change. Almost always the buffer pool plus buffers exceed RAM, or there is a typo in the .cnf. Read the error log with sudo journalctl -u mariadb -n 50 (or -u mysql). Comment out your drop-in, start the server, and re-add settings one block at a time.
The OOM killer is killing mysqld under load. Total memory is over budget. Lower innodb_buffer_pool_size, drop max_connections, and shrink the per-connection buffers, then re-run mysqltuner's memory line.
Changes seem ignored. Another file in the include chain overrides yours. Confirm the effective value with SHOW VARIABLES LIKE '...';, make sure your drop-in sorts last, and check it sits under [mysqld].
The first queries after a restart are slow, then fast. Cold buffer pool. It refills as pages are read. To warm it automatically, keep innodb_buffer_pool_dump_at_shutdown and innodb_buffer_pool_load_at_startup on (both default to ON in modern versions).
Too many connections errors. The app is opening more connections than max_connections allows. Either it is leaking connections (fix the pool), or raise the limit - but mind the memory math. A connection pooler is the real fix.
max_connections can serve a busy app without exhausting RAM. This often beats any single .cnf tweak.ALTER TABLE mytable ENGINE=InnoDB;. You get row-level locking, crash recovery, and the buffer pool you just sized.mysqld_exporter gives you buffer pool, connection, and slow-query graphs.A well-tuned MariaDB on a $5 NVMe VPS will comfortably outrun a default install on far bigger hardware. The buffer pool alone gets you most of the way; the slow query log gets you the rest.
Need a fast box to run MariaDB or MySQL on? Our Linux VPS plans ship with NVMe storage, generous RAM, and snapshots so you can resize the buffer pool without fear. See the options.