<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>MySQL - Matteo Cisilino</title>
	<atom:link href="https://www.cisilino.com/en/tag/mysql-en/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.cisilino.com/en/</link>
	<description>Guide su WordPress, server e sicurezza</description>
	<lastBuildDate>Sat, 31 Oct 2026 10:03:49 +0000</lastBuildDate>
	<language>en-GB</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>
	<item>
		<title>Database credential rotation after an ISPConfig breach</title>
		<link>https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/</link>
					<comments>https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Sat, 31 Oct 2026 10:03:56 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[bash script]]></category>
		<category><![CDATA[credential rotation]]></category>
		<category><![CDATA[incident response]]></category>
		<category><![CDATA[ISPConfig]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[server management]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=1412</guid>

					<description><![CDATA[<p>I rotate credentials the moment I suspect a breach. Not after I&#8217;ve confirmed one. Database credential rotation on an ISPConfig server sounds like routine maintenance. It stops being routine the day you find a web shell on a hosted site. Or a configuration file you can&#8217;t explain, sitting in a public backup. At that point, [&#8230;]</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/">Database credential rotation after an ISPConfig breach</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p class="wp-block-paragraph">I rotate credentials the moment I suspect a breach. Not after I&#8217;ve confirmed one. Database credential rotation on an ISPConfig server sounds like routine maintenance. It stops being routine the day you find a web shell on a hosted site. Or a configuration file you can&#8217;t explain, sitting in a public backup. At that point, every password tied to that database stack needs to be new. Verified. Documented. Not just assumed to be fine.</p>



<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/database-credential-rotation-ispconfig.webp" alt="database credential rotation lock and key rotating above an ISPConfig database" width="1200" height="630"/></figure>



<h2 class="wp-block-heading">When does rotation stop being optional?</h2>



<p class="wp-block-paragraph">A few scenarios bring this up often enough that I keep a rotation plan ready for them. I&#8217;d rather not improvise one under pressure.</p>



<p class="wp-block-paragraph">A plugin or CMS on a hosted site gets compromised. I wrote about one real case on this kind of server in <a href="https://www.cisilino.com/en/2026/10/29/php2shell-cve-2026-109618-server-mitigation/">PHP2Shell: how I protected my servers while waiting for the patch</a>, a PHP vulnerability that let an attacker drop a web shell before a patch existed. Once a web shell runs under a hosting user, it can often read that site&#8217;s own config.inc.php. On a shared ISPConfig box, that file sits next to credentials for the control panel&#8217;s own database. Not only the one credential the attacker was actually after.</p>



<p class="wp-block-paragraph">A configuration file ends up somewhere it shouldn&#8217;t. A backup archive left in a public folder. A repository pushed with a stray file in it. A log that&#8217;s more readable than anyone intended. config.inc.php and mysql_clientdb.conf have no business in any of those places. Mistakes happen anyway, including mine.</p>



<p class="wp-block-paragraph">A system administrator leaves the team. However amicable the departure, shared database credentials they once held are credentials I no longer fully trust. Trust doesn&#8217;t transfer automatically when someone walks out the door.</p>



<p class="wp-block-paragraph">A suspected intrusion, even an unconfirmed one. This case is hardest to explain to anyone who hasn&#8217;t run a server under suspicion of attack. You don&#8217;t wait for proof before acting. You assume compromise and rotate. An unnecessary rotation costs an afternoon. A skipped one, after a real breach, can cost every site the server hosts.</p>



<h2 class="wp-block-heading">Why manual database credential rotation is so error-prone</h2>



<p class="wp-block-paragraph">The first time I rotated ISPConfig&#8217;s database credentials by hand, I underestimated how many files were involved. They all have to change together, or something quietly breaks a few hours later.</p>



<p class="wp-block-paragraph">There&#8217;s config.inc.php on the server side, and a second copy on the interface side. Both need the same new values. There&#8217;s mysql_clientdb.conf, which ISPConfig reads to manage every hosted site&#8217;s own database, not just its own housekeeping. Dovecot, Pure-FTPd and Postfix each keep their own copy of some of the same credentials. Mail and FTP can stop authenticating silently if you update the database user and forget just one of those three.</p>



<p class="wp-block-paragraph">Then there&#8217;s the Master/Slave topology some of my setups use. A Slave node authenticates to its Master&#8217;s database over the network, under a separate password. That password has to be rotated on the Master itself, not only locally. Get the sequence wrong, and a Slave can lock itself out of the very Master it&#8217;s supposed to be replicating from.</p>



<p class="wp-block-paragraph">None of that is conceptually difficult. It&#8217;s a lot of files. Several services to restart, in a sensible order. Almost no tolerance for a typo, especially while you&#8217;re doing it under pressure, right after finding something you didn&#8217;t want to find. That combination is exactly why database credential rotation kept feeling riskier by hand than the task itself should be. It&#8217;s why I wrote reset_DB_password.sh, after living through the problem myself more than once.</p>



<h2 class="wp-block-heading">What reset_DB_password.sh actually does</h2>



<p class="wp-block-paragraph">reset_DB_password.sh detects whether the node is Standalone, Master or Slave. It then runs only the phases that role needs.</p>



<p class="wp-block-paragraph">Phase A rotates the local MySQL root password. That&#8217;s the password ISPConfig itself uses to manage every hosted site&#8217;s database. The script updates mysql_clientdb.conf to match the new value.</p>



<p class="wp-block-paragraph">Phase B rotates the ispconfig control user&#8217;s password. It updates config.inc.php on both the server and interface side. Then it updates the matching credentials inside Dovecot, Pure-FTPd and Postfix, so mail and FTP keep authenticating without a separate manual pass through three more config files.</p>



<p class="wp-block-paragraph">Phase C runs only on a Slave. It connects to the Master&#8217;s database remotely. It rotates the Slave user&#8217;s password there, then updates dbmaster_password locally, so replication keeps authenticating correctly against the new value.</p>



<p class="wp-block-paragraph">After Phase B, the script offers to restart Dovecot, Pure-FTPd and Postfix for you. I almost always say yes. A credential rotation that leaves a mail service running on the old password isn&#8217;t finished, it&#8217;s just quiet about being broken. Restarting in the same run, right after the config files change, keeps the whole thing inside one short maintenance window instead of three separate ones discovered later.</p>



<h2 class="wp-block-heading">Backups before anything changes</h2>



<p class="wp-block-paragraph">Before touching a single file, the script takes a full rsync copy of /usr/local/ispconfig/ into a timestamped folder. It also attempts a non-blocking mysqldump of the mysql and dbispconfig databases. Non-blocking on purpose: I&#8217;d rather finish a credential rotation than sit waiting on a dump that&#8217;s stuck for some unrelated reason. Every configuration file it edits gets its own .bak copy first, before any string inside it gets replaced.</p>



<p class="wp-block-paragraph">None of that replaces an independent backup strategy. I still take a separate snapshot, or a full backup, before running this on any server that actually matters. The script&#8217;s own disclaimer says the same thing, more bluntly than I just did.</p>



<h2 class="wp-block-heading">Dry run first, every time</h2>



<p class="wp-block-paragraph">Every phase asks for confirmation before changing anything. &#8211;dry-run simulates an entire run without touching a single file. I use dry-run first on every server, every time. Even on ones I&#8217;ve rotated credentials on before, and think I know well. &#8211;skip-local-root skips Phase A, for when only the control user&#8217;s password needs rotating. &#8211;role=master or &#8211;role=slave overrides the automatic detection, for the rare case where it doesn&#8217;t read a server&#8217;s topology correctly on its own.</p>



<p class="wp-block-paragraph">The passwords it generates are 32 characters, letters and digits only. Long enough to resist guessing. Plain enough that MySQL&#8217;s and PHP&#8217;s own parsers never trip over a stray symbol in a connection string. I&#8217;ve tested the script on ISPConfig 3.3.1p1. I haven&#8217;t run it against older or newer branches. I wouldn&#8217;t assume identical behaviour there, without checking first myself.</p>



<p class="wp-block-paragraph">A node&#8217;s role isn&#8217;t always obvious at a glance, especially on a server you inherited rather than built yourself. That&#8217;s why &#8211;role exists, and why the dry-run output is worth reading line by line the first time, not skimmed. I&#8217;d rather spend two minutes confirming a Slave is really a Slave than find out the hard way mid-rotation.</p>



<h2 class="wp-block-heading">What I&#8217;d tell you before you run it</h2>



<p class="wp-block-paragraph"><strong>Disclaimer</strong>: the script is provided as-is, for informational purposes. You use it entirely at your own risk: I accept no responsibility for data loss, downtime, or any error caused by incorrect use or by running it in a context different from the one I use it in myself. Always keep an independent backup or VM snapshot before running it in production, regardless of what the script&#8217;s own backup step already captures for you.</p>



<p class="wp-block-paragraph">It&#8217;s free software. You can redistribute and modify it, with attribution to me as the original author. The source for reset_DB_password.sh, and the rest of the ispconfig-fast-tools collection, is on GitHub at <a href="https://github.com/matteocisilino/ispconfig-fast-tools" target="_blank" rel="noopener">github.com/matteocisilino/ispconfig-fast-tools</a>.</p>



<h2 class="wp-block-heading">Database credential rotation is a habit, not a cleanup job</h2>



<p class="wp-block-paragraph">Database credential rotation after a confirmed breach is just competent incident response. Most administrators would do it without being told twice. The harder habit is doing it after a merely suspected one, before anyone has proof. That&#8217;s the only version of the plan that actually limits the damage, while it&#8217;s still limitable. A script that runs the right phases for a server&#8217;s role, takes its own backups first, and lets me dry-run the whole thing before touching anything real. That&#8217;s what makes the habit practical on a Tuesday night, instead of theoretical in a blog post.</p>



<p class="wp-block-paragraph">ISPConfig, MySQL, PHP, Dovecot, Pure-FTPd, Postfix and the other names mentioned are trademarks of their respective owners. They are used only to identify the products.</p><p>The post <a href="https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/">Database credential rotation after an ISPConfig breach</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Which database is using up your disk space? A script for MySQL and PostgreSQL</title>
		<link>https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/</link>
					<comments>https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Tue, 20 Oct 2026 07:53:00 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[bash script]]></category>
		<category><![CDATA[disk space]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=256</guid>

					<description><![CDATA[<p>The server disk is filling up and you don't know which database is to blame? A script to measure the space used by MySQL and PostgreSQL and compare it over time.</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/">Which database is using up your disk space? A script for MySQL and PostgreSQL</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>It happens to everyone sooner or later. The server disk fills up, the alarm goes off and the question is always the same. <strong>Which database is taking up all this space?</strong> Measuring database disk space properly turns out to be harder than it looks.</p>
<figure class="wp-block-image size-large"><img decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/mysql-postgresql-database-disk-usage-script.webp" alt="Database disk space by engine: ring chart split into four coloured segments, with three legend bars on the right" width="1200" height="630"/></figure>
<p>I ran into it after an optimisation. I had run <code>OPTIMIZE TABLE</code> hoping to reclaim space, but the filesystem didn&#8217;t shrink as expected. Without a &#8220;before&#8221; to compare with the &#8220;after&#8221;, it was impossible to tell whether the optimisation had worked. Nor could I tell which database was really responsible, or whether the missing space was somewhere else entirely.</p>
<p>The tools I had answered only half of the problem:</p>
<ul class="wp-block-list">
<li><code>du</code> shows the files on disk, but doesn&#8217;t say which table they belong to;</li>
<li><code>information_schema</code> (or <code>pg_class</code> on PostgreSQL) gives the logical sizes, but doesn&#8217;t see binlogs, <code>ibdata1</code>, undo logs or WAL;</li>
<li>both photograph a single instant: comparing over time remains manual work, done with copy and paste and a spreadsheet.</li>
</ul>
<p>I needed something simple: <strong>take a snapshot of the state of all databases at a given moment, take another later and have it tell me what changed</strong>, sorting the differences from the largest to the smallest. That way the culprit jumps out immediately.</p>
<p>The result is a single Bash script. It has no dependencies beyond the database client and the standard Linux tools (<code>awk</code>, <code>du</code>, <code>df</code>). It works with <strong>MySQL/MariaDB</strong> and with <strong>PostgreSQL</strong>. I&#8217;m sharing it here in the hope it saves someone the same afternoon of frustration.</p>
<h2 class="wp-block-heading">What the database disk space script does</h2>
<p>The script has four commands:</p>
<ul class="wp-block-list">
<li><code>snapshot</code> saves the current state (databases, tables, files in the data directory, filesystem space);</li>
<li><code>compare</code> compares two snapshots and shows the differences, the largest on top;</li>
<li><code>report</code> shows the largest databases and tables in a single snapshot;</li>
<li><code>list</code> lists the saved snapshots.</li>
</ul>
<p>The comparison is split into four sections, so as not to leave any blind spots:</p>
<ul class="wp-block-list">
<li><strong>size of each database</strong> (data + indexes);</li>
<li><strong>free or reclaimable space</strong> (<code>data_free</code> on MySQL, an estimate of dead tuples on PostgreSQL);</li>
<li><strong>size of each table</strong>;</li>
<li><strong>actual files in the data directory</strong>: database folders, <code>ibdata1</code>, binlogs, undo, <code>pg_wal</code>.</li>
</ul>
<p>The last section is often the one that solves the mystery. Many times the &#8220;anomalous&#8221; space doesn&#8217;t belong to any database, but to the binary logs or to InnoDB&#8217;s shared files.</p>
<h2 class="wp-block-heading">Requirements</h2>
<ul class="wp-block-list">
<li>Linux with Bash 4 or later, <code>awk</code>, <code>du</code>, <code>df</code> (all standard);</li>
<li>the <code>mysql</code> or <code>psql</code> client, with a user who can read the metadata of all databases;</li>
<li>root permissions (or those of the <code>mysql</code>/<code>postgres</code> user) if you also want to measure the files on disk; without them, that section is skipped with a warning.</li>
</ul>
<h2 class="wp-block-heading">Installation</h2>
<p>Save the script (you will find it at the end of the article) to a file and make it executable:</p>
<pre class="wp-block-code"><code>nano db_space_compare.sh
chmod +x db_space_compare.sh</code></pre>
<h3 class="wp-block-heading">Credentials</h3>
<p>For MySQL/MariaDB the cleanest way is a <code>~/.my.cnf</code> file with the credentials, so they don&#8217;t appear in the command history:</p>
<pre class="wp-block-code"><code>&#91;client]
user=root
password=LA_TUA_PASSWORD</code></pre>
<p>Alternatively you can pass the options through an environment variable:</p>
<pre class="wp-block-code"><code>export MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"</code></pre>
<p>For PostgreSQL, <code>~/.pgpass</code>, the <code>PG*</code> variables or <code>PG_OPTS</code> work:</p>
<pre class="wp-block-code"><code>export PG_OPTS="-U postgres -h localhost"</code></pre>
<h2 class="wp-block-heading">How to use it</h2>
<p>The typical flow is <em>before, optimisation, after, comparison</em>:</p>
<pre class="wp-block-code"><code># 1. fotografia prima dell'ottimizzazione
sudo ./db_space_compare.sh snapshot prima

# 2. esegui l'ottimizzazione (OPTIMIZE TABLE, VACUUM FULL, ...)

# 3. fotografia dopo
sudo ./db_space_compare.sh snapshot dopo

# 4. confronto
sudo ./db_space_compare.sh compare prima dopo</code></pre>
<p>For PostgreSQL just add the <code>-e postgres</code> option:</p>
<pre class="wp-block-code"><code>sudo ./db_space_compare.sh -e postgres snapshot prima
sudo ./db_space_compare.sh -e postgres snapshot dopo
sudo ./db_space_compare.sh -e postgres compare prima dopo</code></pre>
<p>The available options are:</p>
<ul class="wp-block-list">
<li><code>-e mysql|postgres</code> chooses the engine (default: <code>mysql</code>);</li>
<li><code>-d DIR</code> changes the folder where snapshots are saved (default: <code>./db_snapshots</code>);</li>
<li><code>-n N</code> sets how many rows to show per section (default: 20).</li>
</ul>
<p>If you don&#8217;t give a label, the snapshot is named after the date and time. This is handy for monitoring growth over time.</p>
<h2 class="wp-block-heading">Example output</h2>
<p>Here is a comparison on test data, built on purpose to show a typical case. The <code>shop</code> database shrinks after the optimisation, but the <code>log</code> database grows and the binlogs double.</p>
<pre class="wp-block-code"><code>Confronto 'prima' (2026-10-11 20:00:00)  -&gt;  'dopo' (2026-10-11 22:00:00)
Filesystem /var: usato 46.6 GiB -&gt; 48.4 GiB (+1.9 GiB)

=== Database - dimensione totale ===
  NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
  log                                                    21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
  shop                                                    5.6 GiB      3.6 GiB      -2.0 GiB  -35.0%

=== Tabelle - dimensione (dati + indici) ===
  NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
  log.eventi                                             21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
  shop.ordini                                             5.6 GiB      3.6 GiB      -2.0 GiB  -35.0%
  log.nuova                                                   0 B      1.0 KiB      +1.0 KiB  NUOVO

=== File nella data directory (cartelle DB, ibdata1, binlog, undo, WAL) ===
  NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
  log                                                    21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
  mysql-bin.*                                             3.7 GiB      8.4 GiB      +4.7 GiB  +125.0%
  shop                                                    5.7 GiB      3.6 GiB      -2.0 GiB  -36.1%
  ibdata1                                                11.2 GiB     11.2 GiB           0 B</code></pre>
<p>Here the culprit is obvious: the <code>log.eventi</code> table has grown by more than 6 GiB. The <code>mysql-bin.*</code> files went from 3.7 to 8.4 GiB. The optimisation on <code>shop</code> worked, but the gain was eaten up by the rest.</p>
<h2 class="wp-block-heading">How to read the database disk space results</h2>
<ul class="wp-block-list">
<li>At the top of each section are the items that changed the most. A database or table that <strong>doesn&#8217;t shrink</strong> (or even grows) after the optimisation is the first suspect.</li>
<li>If the tables shrink but the filesystem doesn&#8217;t, the space is elsewhere. Look at the section on files in the data directory (binlogs, <code>ibdata1</code>, undo, <code>pg_wal</code>).</li>
<li><strong>MySQL/MariaDB:</strong> <code>ibdata1</code> never shrinks, not even with <code>OPTIMIZE TABLE</code>. With <code>innodb_file_per_table=OFF</code> the value of <code>data_free</code> is shared and not very informative.</li>
<li><strong>PostgreSQL:</strong> a plain <code>VACUUM</code> does not give space back to the operating system, you need <code>VACUUM FULL</code> (or <code>pg_repack</code>). The &#8220;reclaimable&#8221; space shown is an estimate based on dead tuples. The PostgreSQL documentation on <a href="https://www.postgresql.org/docs/current/diskusage.html" target="_blank" rel="noopener">monitoring disk usage</a> covers the underlying functions.</li>
</ul>
<h2 class="wp-block-heading">Monitoring database disk space over time</h2>
<p>If the problem isn&#8217;t tied to a single optimisation but to gradual growth, it is worth automating snapshots with cron, one an hour, and then comparing any two moments:</p>
<pre class="wp-block-code"><code># crontab -e (come root)
0 * * * * /usr/local/bin/db_space_compare.sh -d /var/lib/db_snapshots snapshot &gt;/dev/null 2&gt;&amp;1</code></pre>
<p>Without a label, every snapshot is named after the date and time (for example <code>20261011-140000</code>). With <code>list</code> you see the available ones and with <code>compare</code> you compare two. Within a few days it becomes clear which database is growing abnormally.</p>
<pre class="wp-block-code"><code>./db_space_compare.sh -d /var/lib/db_snapshots list
./db_space_compare.sh -d /var/lib/db_snapshots compare 20261011-140000 20261012-140000</code></pre>
<h2 class="wp-block-heading">The complete script</h2>
<p>Here is the code. Copy it into a file called <code>db_space_compare.sh</code>, make it executable and you are ready.</p>
<pre class="wp-block-code"><code>#!/usr/bin/env bash
#
# db_space_compare.sh
# Fotografa l'occupazione di spazio di tutti i database (MySQL/MariaDB o
# PostgreSQL) e confronta due fotografie (es. prima/dopo l'ottimizzazione)
# per capire quale database, tabella o file occupa spazio in modo anomalo.
#
# Uso:
#   db_space_compare.sh &#91;opzioni] snapshot &#91;etichetta]   (default: data-ora)
#   db_space_compare.sh &#91;opzioni] compare  &lt;prima&gt; &lt;dopo&gt;
#   db_space_compare.sh &#91;opzioni] report   &lt;etichetta&gt;
#   db_space_compare.sh &#91;opzioni] list
#
# Opzioni:
#   -e mysql|postgres   motore del database (default: mysql)
#   -d DIR              cartella delle fotografie (default: ./db_snapshots)
#   -n N                righe mostrate per sezione (default: 20)
#
# Credenziali:
#   MySQL/MariaDB: ~/.my.cnf oppure MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"
#   PostgreSQL:    ~/.pgpass, variabili PG* oppure PG_OPTS="-U postgres -h localhost"
#                  (non mettere -d in PG_OPTS: lo script si collega a ogni database)
#
# Per misurare anche i file su disco (cartelle dei DB, ibdata1, binlog, undo,
# pg_wal) va eseguito come root o come utente mysql/postgres; altrimenti
# quella parte viene saltata con un avviso.
#
# Esempio:
#   sudo ./db_space_compare.sh snapshot prima
#   ... ottimizzazione (OPTIMIZE TABLE / VACUUM FULL / ...) ...
#   sudo ./db_space_compare.sh snapshot dopo
#   sudo ./db_space_compare.sh compare prima dopo

set -euo pipefail
export LC_ALL=C

ENGINE=mysql
OUTDIR=./db_snapshots
TOPN=20
SNAP_IN_PROGRESS=""

die()  { echo "ERRORE: $*" &gt;&amp;2; exit 1; }
warn() { echo "ATTENZIONE: $*" &gt;&amp;2; }
usage() { sed -n '2,/^$/{s/^# \{0,1\}//;p}' "$0"; exit "${1:-0}"; }

cleanup() {
  if &#91;&#91; -n $SNAP_IN_PROGRESS ]]; then
    rm -rf -- "$SNAP_IN_PROGRESS"
    echo "Fotografia incompleta rimossa: $SNAP_IN_PROGRESS" &gt;&amp;2
  fi
}
trap cleanup EXIT

while getopts ":e:d:n:h" opt; do
  case $opt in
    e) ENGINE=$OPTARG ;;
    d) OUTDIR=$OPTARG ;;
    n) TOPN=$OPTARG ;;
    h) usage 0 ;;
    *) usage 1 ;;
  esac
done
shift $((OPTIND - 1))

&#91;&#91; $ENGINE == mysql || $ENGINE == postgres ]] || die "motore non valido: '$ENGINE' (usa mysql o postgres)"
&#91;&#91; $TOPN =~ ^&#91;0-9]+$ ]] || die "-n deve essere un numero"

read -r -a MYSQL_ARGS &lt;&lt;&lt; "${MYSQL_OPTS:-}"
read -r -a PG_ARGS    &lt;&lt;&lt; "${PG_OPTS:-}"

my() { mysql "${MYSQL_ARGS&#91;@]+"${MYSQL_ARGS&#91;@]}"}" --batch --skip-column-names "$@"; }
pg() { psql  "${PG_ARGS&#91;@]+"${PG_ARGS&#91;@]}"}" -X -q -A -t -F $'\t' -v ON_ERROR_STOP=1 "$@"; }

# Funzioni awk comuni: formattazione in KiB/MiB/GiB, chiave e valore di riga.
#   nk = numero di colonne che formano il nome (1 = database, 2 = db.tabella)
#   vc = colonne da sommare per il valore (es. "4,5" = dati + indici)
AWK_LIB='
function h(b,   a, i, u) {
  split("B KiB MiB GiB TiB PiB", u, " ")
  a = (b &lt; 0) ? -b : b; i = 1
  while (a &gt;= 1024 &amp;&amp; i &lt; 6) { a /= 1024; i++ }
  if (i == 1) return sprintf("%s%d B", (b &lt; 0 ? "-" : ""), a)
  return sprintf("%s%.1f %s", (b &lt; 0 ? "-" : ""), a, u&#91;i])
}
function hs(b) { return (b &gt; 0 ? "+" : "") h(b) }
function key(   k, i) { k = $1; for (i = 2; i &lt;= nk; i++) k = k "." $i; return k }
function val(   n, c, i, s) { n = split(vc, c, ","); s = 0; for (i = 1; i &lt;= n; i++) s += $(c&#91;i]); return s }
'

meta_get() { awk -F= -v k="$2" '$1 == k { sub(/^&#91;^=]*=/, ""); print; exit }' "$1/meta.txt"; }

# ---------------------------------------------------------------- raccolta dati

# Occupazione reale su disco delle voci di primo livello della data directory:
# cartelle dei database, ibdata1, binlog, undo, redo log, pg_wal, base, ...
# I file numerati (mysql-bin.000123, relay-log.000045, ...) sono sommati in una voce.
collect_disk() {
  local d=$1 out=$2
  : &gt; "$out"
  if &#91;&#91; -z $d || ! -r $d || ! -x $d ]]; then
    warn "data directory '${d:-?}' non leggibile: salto i file su disco (esegui come root per includerli)"
    return 0
  fi
  { du -sb -- "$d"/* 2&gt;/dev/null || true; } |
    awk -F'\t' -v p="$d/" '
      { n = $2
        if (index(n, p) == 1) n = substr(n, length(p) + 1)
        if (n ~ /\.&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]$/) sub(/&#91;0-9]+$/, "*", n)
        s&#91;n] += $1 }
      END { for (n in s) printf "%s\t%.0f\n", n, s&#91;n] }' &gt; "$out"
}

write_meta() {
  local dir=$1 datadir=$2 version=$3
  {
    echo "timestamp=$(date '+%Y-%m-%d %H:%M:%S')"
    echo "engine=$ENGINE"
    echo "host=$(hostname)"
    echo "version=$version"
    echo "datadir=$datadir"
    if &#91;&#91; -n $datadir &amp;&amp; -e $datadir ]]; then
      { df -P -B1 -- "$datadir" 2&gt;/dev/null || true; } |
        awk 'NR == 2 { print "fs_mount=" $6; print "fs_used=" $3; print "fs_avail=" $4 }'
    fi
  } &gt; "$dir/meta.txt"
}

# File prodotti da ogni fotografia (separati da TAB):
#   tables.tsv     db, tabella, engine, dati, indici, libero/recuperabile, righe
#   databases.tsv  db, dati, indici, libero/recuperabile, totale
#   disk.tsv       voce della data directory, byte su disco
#   meta.txt       data, host, versione, filesystem

snapshot_mysql() {
  local dir=$1 pre="" datadir version
  command -v mysql &gt;/dev/null 2&gt;&amp;1 || die "client 'mysql' non trovato"
  my -e "SELECT 1" &gt;/dev/null || die "connessione a MySQL/MariaDB fallita (controlla ~/.my.cnf o MYSQL_OPTS)"

  # MySQL 8 tiene in cache le statistiche di information_schema fino a 24 ore:
  # senza questo, i valori "dopo" l'ottimizzazione potrebbero essere quelli vecchi.
  if &#91;&#91; -n $(my -e "SHOW VARIABLES LIKE 'information_schema_stats_expiry'") ]]; then
    pre="SET SESSION information_schema_stats_expiry = 0;"
  fi

  my -e "$pre
    SELECT table_schema, table_name, IFNULL(engine, '-'),
           IFNULL(data_length, 0), IFNULL(index_length, 0), IFNULL(data_free, 0), IFNULL(table_rows, 0)
      FROM information_schema.TABLES
     WHERE table_type IN ('BASE TABLE', 'SYSTEM VERSIONED')
       AND table_schema NOT IN ('information_schema', 'performance_schema', 'sys')" &gt; "$dir/tables.tsv"

  my -e "$pre
    SELECT s.schema_name,
           IFNULL(SUM(t.data_length), 0), IFNULL(SUM(t.index_length), 0), IFNULL(SUM(t.data_free), 0),
           IFNULL(SUM(t.data_length), 0) + IFNULL(SUM(t.index_length), 0)
      FROM information_schema.SCHEMATA s
      LEFT JOIN information_schema.TABLES t
             ON t.table_schema = s.schema_name
            AND t.table_type IN ('BASE TABLE', 'SYSTEM VERSIONED')
     WHERE s.schema_name NOT IN ('information_schema', 'performance_schema', 'sys')
     GROUP BY s.schema_name" &gt; "$dir/databases.tsv"

  datadir=$(my -e "SELECT @@datadir"); datadir=${datadir%/}
  version=$(my -e "SELECT VERSION()")
  collect_disk "$datadir" "$dir/disk.tsv"
  write_meta "$dir" "$datadir" "$version"
}

snapshot_postgres() {
  local dir=$1 maindb=${PGDATABASE:-postgres} datadir version db
  command -v psql &gt;/dev/null 2&gt;&amp;1 || die "client 'psql' non trovato"
  pg -d "$maindb" -c "SELECT 1" &gt;/dev/null || die "connessione a PostgreSQL fallita (controlla PG_OPTS, PGUSER, ~/.pgpass)"

  pg -d "$maindb" -c "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY 1" &gt; "$dir/.dblist"

  # Per ogni database: dimensione di ogni tabella (catalogo incluso, es. pg_largeobject)
  # e stima dello spazio occupato da tuple morte (bloat recuperabile con VACUUM FULL).
  : &gt; "$dir/tables.tsv"
  while IFS= read -r db; do
    &#91;&#91; -n $db ]] || continue
    pg -d "$db" -c "
      SELECT current_database(), s.schemaname || '.' || s.relname, 'table',
             pg_table_size(s.relid), pg_indexes_size(s.relid),
             CASE WHEN s.n_live_tup + s.n_dead_tup &gt; 0
                  THEN (pg_table_size(s.relid)::numeric * s.n_dead_tup / (s.n_live_tup + s.n_dead_tup))::bigint
                  ELSE 0 END,
             s.n_live_tup
        FROM pg_stat_all_tables s
       WHERE s.schemaname &lt;&gt; 'information_schema'
         AND s.schemaname NOT LIKE 'pg_toast%'" &lt; /dev/null &gt;&gt; "$dir/tables.tsv" \
      || warn "impossibile leggere le tabelle del database '$db'"
  done &lt; "$dir/.dblist"

  pg -d "$maindb" -c "SELECT datname, pg_database_size(oid) FROM pg_database WHERE datallowconn AND NOT datistemplate" &gt; "$dir/.dbsize"
  awk -F'\t' '
    FILENAME == ARGV&#91;1] { d&#91;$1] += $4; i&#91;$1] += $5; f&#91;$1] += $6; next }
    { printf "%s\t%.0f\t%.0f\t%.0f\t%s\n", $1, d&#91;$1], i&#91;$1], f&#91;$1], $2 }' \
    "$dir/tables.tsv" "$dir/.dbsize" &gt; "$dir/databases.tsv"

  datadir=$(pg -d "$maindb" -c "SHOW data_directory" 2&gt;/dev/null || true)
  version=$(pg -d "$maindb" -c "SHOW server_version")
  collect_disk "$datadir" "$dir/disk.tsv"

  # Dettaglio di base/&lt;oid&gt; con il nome del database al posto dell'oid
  if &#91;&#91; -n $datadir &amp;&amp; -r $datadir/base &amp;&amp; -x $datadir/base ]]; then
    pg -d "$maindb" -c "SELECT oid, datname FROM pg_database" &gt; "$dir/.oidmap"
    { du -sb -- "$datadir"/base/* 2&gt;/dev/null || true; } |
      awk -F'\t' '
        FILENAME == ARGV&#91;1] { name&#91;$1] = $2; next }
        { o = $2; sub(/.*\//, "", o); printf "base/%s\t%s\n", ((o in name) ? name&#91;o] : o), $1 }' \
        "$dir/.oidmap" - &gt;&gt; "$dir/disk.tsv"
  fi
  write_meta "$dir" "$datadir" "$version"
}

# --------------------------------------------------------------- presentazione

print_fs() {
  local d=$1 used avail mount
  used=$(meta_get "$d" fs_used); avail=$(meta_get "$d" fs_avail); mount=$(meta_get "$d" fs_mount)
  &#91;&#91; -n $used ]] || return 0
  awk -v u="$used" -v a="$avail" -v m="$mount" "$AWK_LIB"'
    BEGIN { printf "Filesystem %s: usato %s, libero %s\n", m, h(u), h(a) }'
}

top_section() {
  local f=$1 nk=$2 vc=$3 title=$4
  echo
  echo "=== $title ==="
  if &#91;&#91; ! -s $f ]]; then echo "  (nessun dato)"; return 0; fi
  awk -F'\t' -v nk="$nk" -v vc="$vc" "$AWK_LIB"'
    { v = val(); printf "%.0f\t%s\t%s\n", v, key(), h(v) }' "$f" |
    sort -t $'\t' -k1,1nr -k2,2 |
    awk -F'\t' -v top="$TOPN" 'NR &lt;= top &amp;&amp; $1 &gt; 0 { printf "  %-60s %12s\n", $2, $3 }'
}

compare_section() {
  local fa=$1 fb=$2 nk=$3 vc=$4 title=$5
  &#91;&#91; -f $fa ]] || fa=/dev/null
  &#91;&#91; -f $fb ]] || fb=/dev/null
  echo
  echo "=== $title ==="
  if &#91;&#91; ! -s $fa &amp;&amp; ! -s $fb ]]; then echo "  (nessun dato)"; return 0; fi
  printf '  %-50s %12s %12s %13s  %s\n' "NOME" "PRIMA" "DOPO" "DIFFERENZA" "NOTE"
  awk -F'\t' -v nk="$nk" -v vc="$vc" "$AWK_LIB"'
    FILENAME == ARGV&#91;1] { k = key(); A&#91;k] = val(); S&#91;k] = 1; next }
                        { k = key(); B&#91;k] = val(); S&#91;k] = 1 }
    END {
      for (k in S) {
        inA = (k in A); inB = (k in B)
        a = inA ? A&#91;k] : 0; b = inB ? B&#91;k] : 0; d = b - a
        if (!inA)                 note = "NUOVO"
        else if (!inB)            note = "RIMOSSO"
        else if (a &gt; 0 &amp;&amp; d != 0) note = sprintf("%+.1f%%", 100 * d / a)
        else                      note = ""
        printf "%.0f\t%s\t%s\t%s\t%s\t%s\n", (d &lt; 0 ? -d : d), k, h(a), h(b), hs(d), note
      }
    }' "$fa" "$fb" |
    sort -t $'\t' -k1,1nr -k2,2 |
    awk -F'\t' -v top="$TOPN" 'NR &lt;= top { printf "  %-50s %12s %12s %13s  %s\n", $2, $3, $4, $5, $6 }'
}

# -------------------------------------------------------------------- comandi

cmd_snapshot() {
  local label=${1:-$(date '+%Y%m%d-%H%M%S')}
  &#91;&#91; $label =~ ^&#91;A-Za-z0-9]&#91;A-Za-z0-9._-]*$ ]] || die "etichetta non valida: usa solo lettere, numeri, . _ -"
  local dir=$OUTDIR/$label
  &#91;&#91; ! -e $dir ]] || die "la fotografia '$label' esiste già ($dir)"
  mkdir -p -- "$dir"
  SNAP_IN_PROGRESS=$dir
  echo "Fotografia '$label' ($ENGINE) in corso..."
  "snapshot_$ENGINE" "$dir"
  rm -f -- "$dir/.dblist" "$dir/.dbsize" "$dir/.oidmap"
  SNAP_IN_PROGRESS=""
  echo "Salvata in $dir ($(wc -l &lt; "$dir/tables.tsv") tabelle)"
  print_fs "$dir"
  top_section "$dir/databases.tsv" 1 5 "Database più grandi"
}

cmd_report() {
  local label=${1:-}
  &#91;&#91; -n $label ]] || die "uso: report &lt;etichetta&gt;"
  local d=$OUTDIR/$label
  &#91;&#91; -f $d/meta.txt ]] || die "fotografia '$label' non trovata in $OUTDIR"
  echo "Fotografia '$label' del $(meta_get "$d" timestamp) - $(meta_get "$d" engine) $(meta_get "$d" version) su $(meta_get "$d" host)"
  print_fs "$d"
  top_section "$d/databases.tsv" 1 5   "Database più grandi (dati + indici)"
  top_section "$d/tables.tsv"    2 4,5 "Tabelle più grandi (dati + indici)"
  top_section "$d/tables.tsv"    2 6   "Tabelle con più spazio recuperabile (data_free / tuple morte stimate)"
  top_section "$d/disk.tsv"      1 2   "Voci più grandi nella data directory"
}

cmd_compare() {
  local a=${1:-} b=${2:-}
  &#91;&#91; -n $a &amp;&amp; -n $b ]] || die "uso: compare &lt;etichetta_prima&gt; &lt;etichetta_dopo&gt;"
  &#91;&#91; $a != "$b" ]] || die "le due etichette sono uguali"
  local da=$OUTDIR/$a db=$OUTDIR/$b
  &#91;&#91; -f $da/meta.txt ]] || die "fotografia '$a' non trovata in $OUTDIR"
  &#91;&#91; -f $db/meta.txt ]] || die "fotografia '$b' non trovata in $OUTDIR"
  &#91;&#91; $(meta_get "$da" engine) == "$(meta_get "$db" engine)" ]] || warn "le due fotografie sono di motori diversi"

  echo "Confronto '$a' ($(meta_get "$da" timestamp))  -&gt;  '$b' ($(meta_get "$db" timestamp))"
  local ua ub m
  ua=$(meta_get "$da" fs_used); ub=$(meta_get "$db" fs_used); m=$(meta_get "$db" fs_mount)
  if &#91;&#91; -n $ua &amp;&amp; -n $ub ]]; then
    awk -v a="$ua" -v b="$ub" -v m="$m" "$AWK_LIB"'
      BEGIN { printf "Filesystem %s: usato %s -&gt; %s (%s)\n", m, h(a), h(b), hs(b - a) }'
  fi

  compare_section "$da/databases.tsv" "$db/databases.tsv" 1 5   "Database - dimensione totale"
  compare_section "$da/databases.tsv" "$db/databases.tsv" 1 4   "Database - spazio libero/recuperabile"
  compare_section "$da/tables.tsv"    "$db/tables.tsv"    2 4,5 "Tabelle - dimensione (dati + indici)"
  compare_section "$da/disk.tsv"      "$db/disk.tsv"      1 2   "File nella data directory (cartelle DB, ibdata1, binlog, undo, WAL)"

  cat &lt;&lt;'EOF'

Come leggere il risultato:
  - In cima a ogni sezione ci sono le voci cambiate di più: un database o una
    tabella che dopo l'ottimizzazione non cala, o addirittura cresce, è il
    primo sospetto.
  - Se le tabelle calano ma il filesystem no, lo spazio sta altrove: guarda la
    sezione "File nella data directory" (binlog, ibdata1, undo, pg_wal).
  - MySQL/MariaDB: ibdata1 non si restringe mai, nemmeno con OPTIMIZE; con
    innodb_file_per_table=OFF il data_free è condiviso e poco indicativo.
  - PostgreSQL: VACUUM semplice non restituisce spazio al disco, serve
    VACUUM FULL (o pg_repack); lo spazio "recuperabile" è una stima.
EOF
}

cmd_list() {
  if &#91;&#91; ! -d $OUTDIR ]]; then echo "Nessuna fotografia in $OUTDIR"; return 0; fi
  local d found=0
  for d in "$OUTDIR"/*/; do
    &#91;&#91; -f $d/meta.txt ]] || continue
    found=1
    printf '%-24s %s  %-8s  %s\n' "$(basename "$d")" "$(meta_get "$d" timestamp)" \
      "$(meta_get "$d" engine)" "$(meta_get "$d" host)"
  done
  &#91;&#91; $found -eq 1 ]] || echo "Nessuna fotografia in $OUTDIR"
}

cmd=${1:-}
shift || true
case $cmd in
  snapshot) cmd_snapshot "$@" ;;
  compare)  cmd_compare "$@" ;;
  report)   cmd_report "$@" ;;
  list)     cmd_list ;;
  ""|help)  usage 0 ;;
  *)        die "comando sconosciuto: '$cmd' (usa snapshot, compare, report, list)" ;;
esac</code></pre>
<h2 class="wp-block-heading">Final notes on database disk space</h2>
<ul class="wp-block-list">
<li>The script is <strong>read-only</strong> on the databases: it only runs <code>SELECT</code> on the metadata. It writes only to the snapshots folder.</li>
<li>On MySQL 8 it sets <code>information_schema_stats_expiry = 0</code> in the session. Otherwise the cached statistics (up to 24 hours) could show &#8220;old&#8221; values even after the optimisation.</li>
<li>If a snapshot fails halfway, it is removed automatically so as not to leave incomplete data.</li>
<li>Before using it in production, try it in a test environment and check the database user&#8217;s permissions.</li>
</ul>
<p>If you use it and find a case it doesn&#8217;t cover, write to me in the comments. The same goes for ideas to extend it (other engines, CSV export, email alerts). If the problem is a MySQL that won&#8217;t start rather than a full disk, see <a href="https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/">MySQL won&#8217;t start after a Debian 12 upgrade</a>.</p><p>The post <a href="https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/">Which database is using up your disk space? A script for MySQL and PostgreSQL</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>MySQL 5.7 in Docker protected with nftables</title>
		<link>https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/</link>
					<comments>https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Sat, 17 Oct 2026 08:10:45 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[firewall]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[nftables]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=271</guid>

					<description><![CDATA[<p>How to run MySQL 5.7 in Docker on a modern server without exposing it to the internet: docker-compose, a closed port and an nftables firewall.</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/">MySQL 5.7 in Docker protected with nftables</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">Background</h2>
<p>We needed <strong>MySQL 5.7 in Docker</strong>, a legacy database on a server that otherwise runs a recent distribution. Some legacy applications are not compatible with MySQL 8. The reasons: a change of default authentication plugin, stricter SQL modes, new reserved words. Migrating them quickly was not an option.</p>
<figure class="wp-block-image size-large"><img decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/mysql-5-7-docker-nftables-firewall.webp" alt="MySQL 5.7 in Docker: a database inside a box, surrounded by an orange dashed border, with a circle and a bar in front" width="1200" height="630"/></figure>
<p>Installing such an old version directly on the host brings several problems. The official repositories of current distributions no longer offer it. Third-party packages risk conflicting with the rest of the system. An OS upgrade could break it.</p>
<p>So we decided to <strong>isolate it in a Docker container</strong>: the host stays clean and up to date. The database version is fixed and reproducible. Data and configuration live in a folder that is easy to back up and move.</p>
<p>This article tells how we set it up and, above all, the obstacles we met along the way. First the unintentional exposure of port 3306, then an iptables error at reboot. Last, writing a firewall with <strong>nftables</strong> that lets through only authorised IPs.</p>
<blockquote class="wp-block-quote">
<p><strong>Note:</strong> MySQL 5.7 reached end of life (EOL) in October 2023 and no longer receives security patches. It is a stopgap: the goal remains to migrate to MySQL 8.x or MariaDB. That is exactly why it is even more important not to leave it exposed.</p>
</blockquote>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">1. Folder structure</h2>
<pre class="wp-block-code"><code>/opt/mysql57/
├── docker-compose.yml
├── .env
├── conf.d/        # configurazioni personalizzate (my.cnf aggiuntivi)
├── data/          # dati del database (volume persistente)
└── logs/
</code></pre>
<p>The <code>.env</code> file holds the credentials, so they don&#8217;t end up in the compose file:</p>
<pre class="wp-block-code"><code>TZ=Europe/Rome
MYSQL_ROOT_PASSWORD=una_password_molto_lunga_e_casuale
MYSQL_DATABASE=nome_db
MYSQL_USER=nome_utente
MYSQL_PASSWORD=altra_password_robusta
</code></pre>
<p>Protect it with <code>chmod 600 .env</code>.</p>
<h2 class="wp-block-heading">2. MySQL 5.7 in Docker: the docker-compose.yml</h2>
<pre class="wp-block-code"><code>services:
  mysql57:
    image: mysql:5.7
    container_name: mysql57
    restart: unless-stopped
    env_file:
      - .env
    ports:
      - "3306:3306"
    environment:
      TZ: "${TZ:-Europe/Rome}"
      MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
      MYSQL_DATABASE: "${MYSQL_DATABASE}"
      MYSQL_USER: "${MYSQL_USER}"
      MYSQL_PASSWORD: "${MYSQL_PASSWORD}"
    command:
      - --default-authentication-plugin=mysql_native_password
    volumes:
      - ./data:/var/lib/mysql
      - ./conf.d:/etc/mysql/conf.d:ro
      - ./logs:/var/log/mysql
    healthcheck:
      test: &#91;"CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 20s
      timeout: 5s
      retries: 10
      start_period: 45s
    ulimits:
      nofile:
        soft: 65535
        hard: 65535
</code></pre>
<p>A few choices worth noting:</p>
<ul class="wp-block-list">
<li><strong><code>restart: unless-stopped</code></strong>: the database restarts by itself after a server reboot.</li>
<li><strong>Bind-mount volumes</strong>: data, configuration and logs are on the host, so backups and migrations are simple folder copies.</li>
<li><strong>Healthcheck</strong>: lets other services wait until MySQL is really ready (<code>$$</code> makes the variable expand inside the container and not in Compose).</li>
<li><strong><code>ulimits nofile</code></strong>: avoids the &#8220;too many open files&#8221; error under load.</li>
</ul>
<h2 class="wp-block-heading">3. First problem: the port is open to the whole world</h2>
<p>The line <code>- "3306:3306"</code> is equivalent to <code>0.0.0.0:3306:3306</code>. Docker publishes the port on <strong>all interfaces</strong> of the host, including the public one.</p>
<p>The most insidious part is that Docker writes its own rules straight into the kernel firewall. These are evaluated before those of UFW or firewalld. Having a firewall running is <strong>not enough</strong>: the port stays reachable. Docker&#8217;s own documentation on <a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" target="_blank" rel="noopener">packet filtering and firewalls</a> describes this behaviour.</p>
<p>The possible approaches are:</p>
<ol class="wp-block-list">
<li><strong>Localhost only</strong>: <code>"127.0.0.1:3306:3306"</code>, if the database is only used by the host or through an SSH tunnel.</li>
<li><strong>No published port</strong>: if the clients are other containers, a shared Docker network is enough.</li>
<li><strong>Specific IP</strong>: publish only on an address of a private network/VPN.</li>
<li><strong>Public port with IP filtering</strong>: our case, because an external client (a phpMyAdmin instance on another server) has to be able to connect.</li>
</ol>
<p>For case 4 you need a firewall that correctly filters the traffic of the ports published by Docker.</p>
<h2 class="wp-block-heading">4. Second problem: an iptables error at startup</h2>
<p>After touching the firewall, <code>docker compose up</code> returned:</p>
<pre class="wp-block-code"><code>failed to create network mysql57_default: Error response from daemon:
Failed to Setup IP tables: Unable to enable ACCEPT OUTGOING rule:
(iptables failed: iptables --wait -t filter -A DOCKER-FORWARD -i br-... -j ACCEPT:
iptables: No chain/target/match by that name.
</code></pre>
<p>The error doesn&#8217;t depend on the compose file: the <code>DOCKER-FORWARD</code> chain (introduced in recent Docker versions) no longer existed. It had been deleted by something that cleaned up the rules <strong>after</strong> the daemon started. Restarting Docker (<code>systemctl restart docker</code>) recreates it, but the problem comes back until the real culprit is removed.</p>
<h3 class="wp-block-heading">The culprit: <code>flush ruleset</code></h3>
<p>Our <code>/etc/nftables.conf</code> began with:</p>
<pre class="wp-block-code"><code>flush ruleset
</code></pre>
<p>This command deletes <strong>all</strong> nftables tables, including those created by Docker. On top of that the file contained a static copy of Docker&#8217;s rules. The bridge name (<code>br-278550235c3f</code>) and the container IP (<code>172.18.0.2</code>) were written by hand. But the bridge name changes every time the Compose network is recreated, so the copy immediately became obsolete. The <code>ip nat</code> table also clashed with the one managed by Docker.</p>
<p><strong>Golden rule:</strong> with Docker, in the nftables file you don&#8217;t use <code>flush ruleset</code> and you don&#8217;t copy its rules. You manage only your own firewall, in dedicated tables.</p>
<h2 class="wp-block-heading">5. The nftables firewall for MySQL 5.7 in Docker</h2>
<p>The principle is this: the ports published by Docker are rewritten with DNAT and then forwarded by the host to the container, so they go through the <strong><code>forward</code></strong> chain, not <code>input</code>. A <code>drop</code> rule in a chain of ours, <code>forward</code>, with a slightly lower priority (so evaluated first). It drops the packet regardless of Docker&#8217;s <code>accept</code> rules.</p>
<p>To avoid touching other people&#8217;s tables we use the <code>table ...</code> + <code>delete table ...</code> pattern: it makes the file re-runnable without errors and without <code>flush</code>.</p>
<p><code>/etc/nftables.conf</code>:</p>
<pre class="wp-block-code"><code>#!/usr/sbin/nft -f

# NIENTE "flush ruleset": cancellerebbe anche le tabelle di Docker

# ---------------- firewall host ----------------
table inet filter
delete table inet filter

table inet filter {

    chain input {
        type filter hook input priority filter; policy drop;
        ct state established,related accept
        iifname "lo" accept
        ip6 nexthdr icmpv6 accept
        iifname "enp7s0" accept                       # rete privata
        ip saddr 203.0.113.20 tcp dport 22 accept     # SSH da host fidato
        ip saddr 203.0.113.30 tcp dport 22 accept     # SSH da VPN
        ip saddr 203.0.113.20 icmp type echo-request accept
        tcp dport { 80, 443 } accept
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}

# ---------------- guardia MySQL (porta pubblicata da Docker) ----------------
table ip mysql_guard
delete table ip mysql_guard

table ip mysql_guard {
    set allowed {
        type ipv4_addr
        flags interval
        elements = {
            203.0.113.10        # phpMyAdmin
        }
    }

    chain forward {
        type filter hook forward priority filter - 5; policy accept;
        iifname "enp7s0" accept
        ct status dnat tcp dport 3306 ip saddr @allowed accept
        ct status dnat tcp dport 3306 counter drop
    }
}
</code></pre>
<p>How the guard works:</p>
<ul class="wp-block-list">
<li><code>ct status dnat</code> selects only packets that went through DNAT, that is those headed to the ports published by Docker.</li>
<li>The first rule accepts whoever is in the <code>allowed</code> set; the second drops everyone else and counts them (<code>counter</code>).</li>
<li>Local traffic (<code>127.0.0.1</code>) and traffic between containers on the same network doesn&#8217;t go through the router&#8217;s forward. So it isn&#8217;t touched.</li>
</ul>
<blockquote class="wp-block-quote">
<p><strong>Compatibility:</strong> in a first version we used <code>ct original proto-dst 3306</code>, but some versions of <code>nft</code> answer with <em>&#8220;Can&#8217;t parse symbolic invalid expressions&#8221;</em>. In the <code>forward</code> hook the DNAT has already happened and the container port matches the published one (<code>3306:3306</code>). So <code>tcp dport 3306</code> combined with <code>ct status dnat</code> is equivalent and works everywhere. If you change the mapping (e.g. <code>3307:3306</code>), the rule must be adapted to the internal port.</p>
</blockquote>
<h3 class="wp-block-heading">Applying the changes without locking yourself out</h3>
<p>Keep a spare SSH session open, then:</p>
<pre class="wp-block-code"><code>nft -c -f /etc/nftables.conf     # solo verifica della sintassi
nft delete table ip nat          # una tantum: elimina la vecchia copia statica
nft -f /etc/nftables.conf
systemctl restart docker         # Docker ricrea le sue tabelle pulite
cd /opt/mysql57 &amp;&amp; docker compose up -d
</code></pre>
<p>For loading at boot, make sure the service is enabled (<code>systemctl enable --now nftables</code>) and that <code>/etc/nftables.conf</code> does not contain <code>flush ruleset</code>.</p>
<h3 class="wp-block-heading">Managing IPs at runtime</h3>
<pre class="wp-block-code"><code>nft add element ip mysql_guard allowed { 203.0.113.50 }
nft delete element ip mysql_guard allowed { 203.0.113.50 }
</code></pre>
<p>To make it permanent, also update the set in the file.</p>
<h2 class="wp-block-heading">6. Verification</h2>
<p>From the authorised server:</p>
<pre class="wp-block-code"><code>nc -zv IP_PUBBLICO_DB 3306        # deve rispondere "succeeded"
</code></pre>
<p>From any other IP:</p>
<pre class="wp-block-code"><code>nmap -Pn -p 3306 IP_PUBBLICO_DB   # deve risultare "filtered" / timeout
</code></pre>
<p>On the server, the drop rule&#8217;s counter must grow with every unauthorised attempt:</p>
<pre class="wp-block-code"><code>nft list chain ip mysql_guard forward
ss -tlnp | grep 3306
</code></pre>
<h2 class="wp-block-heading">7. Second barrier: the MySQL users</h2>
<p>The firewall doesn&#8217;t replace database permissions. Create the user with the host limited to the client&#8217;s IP:</p>
<pre class="wp-block-code"><code>CREATE USER 'pma_user'@'203.0.113.10' IDENTIFIED BY 'password_robusta';
GRANT ALL ON nome_db.* TO 'pma_user'@'203.0.113.10';
FLUSH PRIVILEGES;
</code></pre>
<p>If the TCP connection succeeds but MySQL answers <code>Access denied</code>, the problem is here and not in the firewall.</p>
<h2 class="wp-block-heading">8. Things to remember about MySQL 5.7 in Docker</h2>
<ul class="wp-block-list">
<li><strong>Docker and the host firewall don&#8217;t ignore each other</strong>. Understanding which chain the traffic goes through (<code>input</code> or <code>forward</code>) is the key to everything.</li>
<li><strong>Never <code>flush ruleset</code></strong> on a host with Docker; use dedicated tables.</li>
<li><strong>Don&#8217;t copy the rules generated by Docker</strong> into your own files: bridge names and IPs change.</li>
<li>If you have already exposed the port, check the logs in <code>./logs</code> and consider <strong>changing the passwords</strong>, especially root&#8217;s.</li>
<li>Where possible, prefer a <strong>VPN (WireGuard) or SSH tunnel</strong> to exposing a database publicly.</li>
<li>Plan the <strong>migration</strong> from MySQL 5.7 to a supported version.</li>
</ul>
<p>Do you have questions or a different solution to the same problem? Write it in the comments. If you run MySQL on Debian, see also <a href="https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/">MySQL won&#8217;t start after a Debian 12 upgrade</a>.</p><p>The post <a href="https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/">MySQL 5.7 in Docker protected with nftables</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>MySQL won&#8217;t start after a Debian 12 upgrade: cause and fix</title>
		<link>https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/</link>
					<comments>https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Tue, 13 Oct 2026 07:50:06 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[Debian 12]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[upgrade]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=255</guid>

					<description><![CDATA[<p>After a Debian 12 upgrade MySQL won't start and the logs mention mariadb.conf.d. How to identify the cause and fix it without touching your data.</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/">MySQL won’t start after a Debian 12 upgrade: cause and fix</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">The symptom: mysql.service in a failed state</h2>
<p>MySQL won&#8217;t start: the service shows as failed and the log reports the error <code>Can't read dir of '/etc/mysql/mariadb.conf.d/'</code>:</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/mysql-wont-start-debian-12-upgrade.webp" alt="MySQL won't start: a cylinder representing a database with an orange warning circle beside it" width="1200" height="630"/></figure>
<pre class="wp-block-code"><code># service mysql status
× mysql.service - MySQL Community Server
     Loaded: loaded (/lib/systemd/system/mysql.service; enabled; preset: enabled)
     Active: failed (Result: exit-code)
     Status: "Server startup in progress"

mysqld: Can't read dir of '/etc/mysql/mariadb.conf.d/' (OS errno 2 - No such file or directory)
mysqld: &#91;ERROR] Stopped processing the 'includedir' directive in file /etc/mysql/my.cnf at line 29.
mysqld: &#91;ERROR] Fatal error in defaults handling. Program aborted!
systemd&#91;1]: Failed to start mysql.service - MySQL Community Server.</code></pre>
<p>Checking the installed packages, MySQL is there as expected (<code>mysql-community-server</code> 8.0 packages from the official Oracle repository):</p>
<pre class="wp-block-code"><code>dpkg -l | grep mysql</code></pre>
<h2 class="wp-block-heading">The cause: the my.cnf alternative points to mariadb.cnf</h2>
<p>On Debian the file <code>/etc/mysql/my.cnf</code> is not a real file but a link managed by <strong>update-alternatives</strong>. Several packages can register their own version and, in automatic mode, the one with the highest priority wins.</p>
<p>During the upgrade the <strong>mariadb-common</strong> package was installed (and later removed), which registers <code>/etc/mysql/mariadb.cnf</code> with <strong>priority 500</strong>, higher than <code>/etc/mysql/mysql.cnf</code> (priority 300). The package was removed but not fully purged (state <code>rc</code>), so its configuration stayed active. On line 29 of that file there is an include of <code>/etc/mysql/mariadb.conf.d/</code>, a directory that does not exist. MySQL reads the configuration, cannot find the directory and stops, which is why MySQL won&#8217;t start.</p>
<p><strong>Good news:</strong> the data in <code>/var/lib/mysql</code> has not been touched. It is only a configuration problem.</p>
<h2 class="wp-block-heading">Diagnosis: check where my.cnf points</h2>
<pre class="wp-block-code"><code>ls -l /etc/mysql/my.cnf /etc/alternatives/my.cnf
update-alternatives --display my.cnf
sed -n '25,32p' /etc/mysql/my.cnf
dpkg -l | grep -i mariadb</code></pre>
<p>If this is the problem, the output will look like this:</p>
<pre class="wp-block-code"><code>/etc/alternatives/my.cnf -&gt; /etc/mysql/mariadb.cnf
/etc/mysql/my.cnf -&gt; /etc/alternatives/my.cnf

my.cnf - modalità automatica
  la versione migliore del collegamento è /etc/mysql/mariadb.cnf
  il collegamento attualmente punta a /etc/mysql/mariadb.cnf
/etc/mysql/mariadb.cnf - priorità 500
/etc/mysql/my.cnf.fallback - priorità 100
/etc/mysql/mysql.cnf - priorità 300

!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mariadb.conf.d/

rc  mariadb-common   1:10.11.18-0+deb12u1   all   MariaDB common configuration files</code></pre>
<h2 class="wp-block-heading">Step-by-step fix</h2>
<h3 class="wp-block-heading">1. Back up the configuration</h3>
<pre class="wp-block-code"><code>cp -a /etc/mysql /root/etc-mysql-backup-$(date +%F)</code></pre>
<h3 class="wp-block-heading">2. Remove the mariadb-common leftovers</h3>
<p>The purge removes <code>mariadb.cnf</code> and its entry in update-alternatives:</p>
<pre class="wp-block-code"><code>apt purge mariadb-common</code></pre>
<h3 class="wp-block-heading">3. Point my.cnf back to mysql.cnf</h3>
<pre class="wp-block-code"><code>update-alternatives --remove my.cnf /etc/mysql/mariadb.cnf 2&gt;/dev/null
update-alternatives --set my.cnf /etc/mysql/mysql.cnf</code></pre>
<h3 class="wp-block-heading">4. Check the configuration</h3>
<pre class="wp-block-code"><code>update-alternatives --display my.cnf
cat /etc/mysql/my.cnf
my_print_defaults mysqld</code></pre>
<p>Now <code>my.cnf</code> must point to <code>/etc/mysql/mysql.cnf</code> and include <code>/etc/mysql/conf.d/</code> and <code>/etc/mysql/mysql.conf.d/</code>. If <code>my_print_defaults mysqld</code> prints the parameters without errors, the configuration is correct.</p>
<h3 class="wp-block-heading">5. Start MySQL</h3>
<pre class="wp-block-code"><code>systemctl start mysql
systemctl status mysql
journalctl -u mysql -n 50 --no-pager</code></pre>
<h2 class="wp-block-heading">If MySQL won&#8217;t start and my.cnf is not a symbolic link</h2>
<p>If <code>/etc/mysql/my.cnf</code> is a regular file or <code>/etc/mysql/mysql.cnf</code> does not exist, you can recreate the configuration by hand:</p>
<pre class="wp-block-code"><code>mv /etc/mysql/my.cnf /etc/mysql/my.cnf.mariadb.bak
cat &gt; /etc/mysql/my.cnf &lt;&lt;'EOF'
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mysql.conf.d/
EOF
mkdir -p /etc/mysql/conf.d
systemctl start mysql</code></pre>
<h2 class="wp-block-heading">Quick temporary fix when MySQL won&#8217;t start</h2>
<p>If you need to bring the database back online right away, just create the missing directory. It is only a stopgap, though. MySQL would keep reading the configuration meant for MariaDB. Better to apply the complete fix as soon as possible.</p>
<pre class="wp-block-code"><code>mkdir /etc/mysql/mariadb.conf.d &amp;&amp; systemctl start mysql</code></pre>
<h2 class="wp-block-heading">How to stop it happening again</h2>
<p>It is worth finding out which upgrade installed MariaDB, by looking at the apt history:</p>
<pre class="wp-block-code"><code>grep -i -B3 -A3 mariadb /var/log/apt/history.log*</code></pre>
<p>To stop <code>mariadb-common</code> being reinstalled in the future you can block it with an APT pin at a negative priority:</p>
<pre class="wp-block-code"><code>cat &gt; /etc/apt/preferences.d/no-mariadb &lt;&lt;'EOF'
Package: mariadb-*
Pin: release *
Pin-Priority: -1
EOF</code></pre>
<p>After every major upgrade it is also good practice to check the state of the alternative with <code>update-alternatives --display my.cnf</code>. Before the next upgrade, read the <a href="https://www.debian.org/releases/bookworm/amd64/release-notes/" target="_blank" rel="noopener">Debian 12 release notes</a> for known issues. A related case is <a href="https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/">MySQL 5.7 in Docker protected with nftables</a>.</p>
<h2 class="wp-block-heading">Frequently asked questions</h2>
<h3 class="wp-block-heading">Will I lose my database data?</h3>
<p>No. The problem only concerns the configuration files in <code>/etc/mysql</code>; the data in <code>/var/lib/mysql</code> stays intact.</p>
<h3 class="wp-block-heading">Why did Debian choose mariadb.cnf?</h3>
<p>Because the <code>my.cnf</code> alternative was in automatic mode and <code>mariadb.cnf</code> has priority 500, higher than the 300 of <code>mysql.cnf</code>. With <code>update-alternatives --set</code> the choice becomes manual and no longer changes by itself.</p>
<h3 class="wp-block-heading">Does it apply to Ubuntu too?</h3>
<p>Yes, the <code>update-alternatives</code> mechanism for <code>my.cnf</code> is the same on Ubuntu and on the other Debian-derived distributions.</p>
<h2 class="wp-block-heading">Conclusion</h2>
<p>The error <strong>&#8220;Can&#8217;t read dir of /etc/mysql/mariadb.conf.d/&#8221;</strong> after a Debian upgrade does not mean MySQL is damaged. It means a leftover MariaDB configuration has stayed active through update-alternatives. By removing <code>mariadb-common</code> with <code>apt purge</code> and setting <code>my.cnf</code> back to <code>mysql.cnf</code>, MySQL works normally again. If MySQL won&#8217;t start after an upgrade, check update-alternatives first.</p></p><p>The post <a href="https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/">MySQL won’t start after a Debian 12 upgrade: cause and fix</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
