MySQL 5.7 in Docker protected with nftables

Background

We needed MySQL 5.7 in Docker, 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.

MySQL 5.7 in Docker: a database inside a box, surrounded by an orange dashed border, with a circle and a bar in front

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.

So we decided to isolate it in a Docker container: 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.

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 nftables that lets through only authorised IPs.

Note: 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.


1. Folder structure

/opt/mysql57/
├── docker-compose.yml
├── .env
├── conf.d/        # configurazioni personalizzate (my.cnf aggiuntivi)
├── data/          # dati del database (volume persistente)
└── logs/

The .env file holds the credentials, so they don’t end up in the compose file:

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

Protect it with chmod 600 .env.

2. MySQL 5.7 in Docker: the docker-compose.yml

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: ["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

A few choices worth noting:

  • restart: unless-stopped: the database restarts by itself after a server reboot.
  • Bind-mount volumes: data, configuration and logs are on the host, so backups and migrations are simple folder copies.
  • Healthcheck: lets other services wait until MySQL is really ready ($$ makes the variable expand inside the container and not in Compose).
  • ulimits nofile: avoids the “too many open files” error under load.

3. First problem: the port is open to the whole world

The line - "3306:3306" is equivalent to 0.0.0.0:3306:3306. Docker publishes the port on all interfaces of the host, including the public one.

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 not enough: the port stays reachable. Docker’s own documentation on packet filtering and firewalls describes this behaviour.

The possible approaches are:

  1. Localhost only: "127.0.0.1:3306:3306", if the database is only used by the host or through an SSH tunnel.
  2. No published port: if the clients are other containers, a shared Docker network is enough.
  3. Specific IP: publish only on an address of a private network/VPN.
  4. Public port with IP filtering: our case, because an external client (a phpMyAdmin instance on another server) has to be able to connect.

For case 4 you need a firewall that correctly filters the traffic of the ports published by Docker.

4. Second problem: an iptables error at startup

After touching the firewall, docker compose up returned:

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.

The error doesn’t depend on the compose file: the DOCKER-FORWARD chain (introduced in recent Docker versions) no longer existed. It had been deleted by something that cleaned up the rules after the daemon started. Restarting Docker (systemctl restart docker) recreates it, but the problem comes back until the real culprit is removed.

The culprit: flush ruleset

Our /etc/nftables.conf began with:

flush ruleset

This command deletes all nftables tables, including those created by Docker. On top of that the file contained a static copy of Docker’s rules. The bridge name (br-278550235c3f) and the container IP (172.18.0.2) were written by hand. But the bridge name changes every time the Compose network is recreated, so the copy immediately became obsolete. The ip nat table also clashed with the one managed by Docker.

Golden rule: with Docker, in the nftables file you don’t use flush ruleset and you don’t copy its rules. You manage only your own firewall, in dedicated tables.

5. The nftables firewall for MySQL 5.7 in Docker

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 forward chain, not input. A drop rule in a chain of ours, forward, with a slightly lower priority (so evaluated first). It drops the packet regardless of Docker’s accept rules.

To avoid touching other people’s tables we use the table ... + delete table ... pattern: it makes the file re-runnable without errors and without flush.

/etc/nftables.conf:

#!/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
    }
}

How the guard works:

  • ct status dnat selects only packets that went through DNAT, that is those headed to the ports published by Docker.
  • The first rule accepts whoever is in the allowed set; the second drops everyone else and counts them (counter).
  • Local traffic (127.0.0.1) and traffic between containers on the same network doesn’t go through the router’s forward. So it isn’t touched.

Compatibility: in a first version we used ct original proto-dst 3306, but some versions of nft answer with “Can’t parse symbolic invalid expressions”. In the forward hook the DNAT has already happened and the container port matches the published one (3306:3306). So tcp dport 3306 combined with ct status dnat is equivalent and works everywhere. If you change the mapping (e.g. 3307:3306), the rule must be adapted to the internal port.

Applying the changes without locking yourself out

Keep a spare SSH session open, then:

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 && docker compose up -d

For loading at boot, make sure the service is enabled (systemctl enable --now nftables) and that /etc/nftables.conf does not contain flush ruleset.

Managing IPs at runtime

nft add element ip mysql_guard allowed { 203.0.113.50 }
nft delete element ip mysql_guard allowed { 203.0.113.50 }

To make it permanent, also update the set in the file.

6. Verification

From the authorised server:

nc -zv IP_PUBBLICO_DB 3306        # deve rispondere "succeeded"

From any other IP:

nmap -Pn -p 3306 IP_PUBBLICO_DB   # deve risultare "filtered" / timeout

On the server, the drop rule’s counter must grow with every unauthorised attempt:

nft list chain ip mysql_guard forward
ss -tlnp | grep 3306

7. Second barrier: the MySQL users

The firewall doesn’t replace database permissions. Create the user with the host limited to the client’s IP:

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;

If the TCP connection succeeds but MySQL answers Access denied, the problem is here and not in the firewall.

8. Things to remember about MySQL 5.7 in Docker

  • Docker and the host firewall don’t ignore each other. Understanding which chain the traffic goes through (input or forward) is the key to everything.
  • Never flush ruleset on a host with Docker; use dedicated tables.
  • Don’t copy the rules generated by Docker into your own files: bridge names and IPs change.
  • If you have already exposed the port, check the logs in ./logs and consider changing the passwords, especially root’s.
  • Where possible, prefer a VPN (WireGuard) or SSH tunnel to exposing a database publicly.
  • Plan the migration from MySQL 5.7 to a supported version.

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 MySQL won’t start after a Debian 12 upgrade.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

WordPress Cookie Notice by Real Cookie Banner