<?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>bash script - Matteo Cisilino</title>
	<atom:link href="https://www.cisilino.com/en/tag/bash-script/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>
	</channel>
</rss>
