GuíasGuides · Nivel 3Level 3

WordPress en Docker: la web corporativa con Compose, copias que se restauran, bastionado y un plugin que tumba la webWordPress on Docker: the corporate site with Compose, backups that restore, hardening and a plugin that takes the site down

WordPress y MariaDB con Docker Compose y volúmenes, las contraseñas en un fichero que no va a git, el proxy inverso con TLS, cómo se actualizan núcleo, plugins e imagen, la copia de la base de datos y de wp-content con su prueba de restauración, el bastionado básico y la web caída después de actualizar un plugin.WordPress and MariaDB with Docker Compose and volumes, passwords in a file that never reaches git, the reverse proxy with TLS, how core, plugins and image are updated, backing up the database and wp-content with a restore test, basic hardening, and the site down after a plugin update.

Qué vas a conseguir

La web corporativa en WordPress corriendo en contenedores en un servidor Linux: WordPress y su base de datos definidos en un fichero, los datos en volúmenes, las contraseñas fuera del repositorio, HTTPS delante, una copia diaria que sabes restaurar porque lo has probado y la rutina para cuando una actualización deja la web en blanco.

Antes de empezar

  • Un servidor con Docker y el plugin de Compose (guía de Docker), con espacio para los volúmenes y las copias.
  • El nombre de la web en DNS apuntando al servidor y un certificado (Let's Encrypt o el de la CA corporativa).
  • Un segundo servidor o máquina de pruebas donde restaurar.

1. El compose: WordPress, MariaDB y volúmenes

compose.yaml
services:  db:    image: mariadb:11.4    restart: unless-stopped    environment:      MARIADB_DATABASE: wordpress      MARIADB_USER: wordpress      MARIADB_PASSWORD: ${DB_PASSWORD}      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}    volumes:      - db_data:/var/lib/mysql    healthcheck:      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]      interval: 30s      retries: 5   wordpress:    image: wordpress:6.9-php8.3-apache    restart: unless-stopped    depends_on:      db:        condition: service_healthy    environment:      WORDPRESS_DB_HOST: db      WORDPRESS_DB_NAME: wordpress      WORDPRESS_DB_USER: wordpress      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}      WORDPRESS_CONFIG_EXTRA: |        define('DISALLOW_FILE_EDIT', true);    volumes:      - wp_html:/var/www/html    ports:      - "127.0.0.1:8080:80"   wpcli:    image: wordpress:cli-php8.3    profiles: ["tools"]    user: "33:33"    environment:      HOME: /tmp      WORDPRESS_DB_HOST: db      WORDPRESS_DB_NAME: wordpress      WORDPRESS_DB_USER: wordpress      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}    volumes:      - wp_html:/var/www/html volumes:  db_data:  wp_html:
  • Etiquetas fijas, nunca latest: así sabes qué versión corre y una actualización es un cambio que decides tú. La de WordPress que toque cuando lo leas, con su versión de PHP.
  • El puerto se publica solo en 127.0.0.1: a WordPress se llega por el proxy, no directamente.
  • wpcli está en un profile: no arranca con docker compose up, solo cuando lo llamas. Usa el usuario 33 porque es el www-data de la imagen Debian de WordPress; la imagen de WP-CLI usa otro por defecto y daría problemas de permisos.

2. Las contraseñas, fuera de git

Compose lee las variables ${DB_PASSWORD} de un fichero .env junto al compose.yaml. Ese fichero no se sube nunca:

servidor web01 · /srv/web-corporativa
cat .gitignore.envbackups/chmod 600 .envgit check-ignore -v .env.gitignore:1:.env	.env
  • En el repositorio va un .env.example con los nombres de las variables y sin valores.
  • docker compose config muestra el fichero con las variables resueltas: incluidas las contraseñas. No lo pegues en un ticket.
  • Las variables MARIADB_* solo se aplican cuando el volumen de datos está vacío, la primera vez. Cambiar la contraseña en .env después no la cambia en la base de datos: hay que hacerlo con ALTER USER y luego en .env.

Si una contraseña llegó a un commit, cámbiala. Borrarla del historial no deshace que alguien la haya clonado.

3. Proxy inverso con TLS

Un Nginx en el propio servidor termina HTTPS y reenvía a WordPress. Traefik o Caddy sirven igual; lo importante son las cabeceras:

/etc/nginx/conf.d/web-corporativa.conf
server {    listen 80;    server_name www.contoso.example;    return 301 https://$host$request_uri;}server {    listen 443 ssl;    server_name www.contoso.example;    ssl_certificate     /etc/letsencrypt/live/www.contoso.example/fullchain.pem;    ssl_certificate_key /etc/letsencrypt/live/www.contoso.example/privkey.pem;    client_max_body_size 64m;     location = /xmlrpc.php { deny all; }    location ~* ^/wp-content/uploads/.*\.php$ { deny all; }     location / {        proxy_pass http://127.0.0.1:8080;        proxy_set_header Host $host;        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;        proxy_set_header X-Forwarded-Proto $scheme;    }}
  • La imagen oficial ya añade a wp-config.php el código que lee X-Forwarded-Proto. Sin esa cabecera WordPress cree que está en HTTP y aparecen bucles de redirección o contenido mixto.
  • Detrás del proxy, Apache ve la IP del proxy. Si un plugin de seguridad bloquea por IP, necesita saber leer X-Forwarded-For, o bloqueará al proxy entero.

4. Actualizaciones: núcleo, plugins, temas e imagen

Con el volumen montado en /var/www/html, la imagen solo copia WordPress la primera vez, cuando el volumen está vacío. A partir de ahí WordPress se gestiona a sí mismo dentro del volumen. Por eso:

  • Cambiar la etiqueta de la imagen actualiza PHP, Apache y las extensiones, no el núcleo de WordPress.
  • El núcleo, los plugins y los temas se actualizan desde Escritorio › Actualizaciones o con WP-CLI.
servidor web01 · revisar qué hay pendiente
docker compose run --rm wpcli wp core check-updatedocker compose run --rm wpcli wp plugin list --update=availablename            status   update     versiongaleria-pro     active   available  3.4.1formularios-xy  active   available  2.8.0docker compose run --rm wpcli wp plugin update formularios-xy

La rutina que evita sustos:

  1. Copia (apartado 5) justo antes.
  2. Actualizar primero en pruebas y abrir las páginas que importan.
  3. En producción, un plugin cada vez, comprobando la web entre uno y otro.
  4. Plugins y temas que no se usan, fuera: desactivados siguen siendo código que se puede atacar. Deja un tema por defecto como reserva.
  5. La imagen: cambiar la etiqueta en compose.yaml, docker compose pull y docker compose up -d. Para un salto de versión mayor de MariaDB, la variable MARIADB_AUTO_UPGRADE=1 hace que la imagen ejecute la actualización de tablas al arrancar.

5. Copias de la base de datos y de wp-content, y la prueba de restauración

Lo que hay que guardar: la base de datos (entradas, páginas, usuarios, ajustes) y wp-content (subidas, plugins, temas). Más compose.yaml y .env, en un sitio seguro.

servidor web01 · copia diaria
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -uroot -p"$MARIADB_ROOT_PASSWORD" wordpress' | gzip > backups/wp-db-$(date +%F).sql.gzdocker compose exec -T wordpress tar -C /var/www/html -czf - wp-content > backups/wp-content-$(date +%F).tar.gzls -lh backups/-rw-r--r-- 1 root root  18M Oct 30 02:00 wp-db-2026-10-30.sql.gz-rw-r--r-- 1 root root 2.1G Oct 30 02:01 wp-content-2026-10-30.tar.gz
  • --single-transaction hace una copia coherente sin bloquear la web. Las imágenes recientes de MariaDB traen mariadb-dump, no mysqldump.
  • Las copias, fuera del servidor: si se pierde la máquina, se pierden con ella.

La prueba de restauración, una vez al mes en el servidor de pruebas con el mismo compose.yaml:

servidor de pruebas · restaurar
docker compose up -d db wordpressgunzip -c wp-db-2026-10-30.sql.gz | docker compose exec -T db sh -c 'exec mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" wordpress'docker compose exec -T wordpress tar -C /var/www/html -xzf - < wp-content-2026-10-30.tar.gz

Apunta en tu fichero hosts el nombre de la web a la IP de pruebas y comprueba: la portada carga, puedes iniciar sesión, la última entrada publicada está, las imágenes se ven y el formulario de contacto envía. Anota cuánto ha tardado: ese es tu tiempo real de recuperación.

6. Bastionado básico

  • xmlrpc.php bloqueado en el proxy (apartado 3), salvo que una aplicación lo use de verdad. Es la puerta favorita de los ataques de fuerza bruta.
  • Ningún usuario llamado admin, pocos administradores y con segundo factor. Quien solo publica, con el rol Editor. Revisa la lista con wp user list --role=administrator.
  • Edición de ficheros desactivada con DISALLOW_FILE_EDIT: nadie edita PHP desde el navegador. Si todas las actualizaciones las haces tú por WP-CLI, DISALLOW_FILE_MODS también impide instalar o actualizar plugins desde el escritorio.
  • wp-admin restringido a la VPN o a las IPs de la oficina en el proxy, dejando público admin-ajax.php, que usan muchos temas.
  • PHP en uploads bloqueado: un fichero subido nunca debe ejecutarse.
  • WAF: ModSecurity con las reglas OWASP CRS en el proxy, el WAF de la CDN si la hay, o un plugin de seguridad. Empieza en modo detección, revisa los falsos positivos y luego bloquea.

7. El ticket: "la web está caída después de actualizar un plugin"

Marketing actualizó un plugin desde el escritorio. La web muestra Ha habido un error crítico en esta web.

  1. Qué ha fallado: el error está en el registro del contenedor.
servidor web01 · buscar el error
docker compose logs --since 30m wordpress | grep -i "fatal"PHP Fatal error:  Uncaught Error: Call to undefined function galeria_pro_init() in /var/www/html/wp-content/plugins/galeria-pro/galeria-pro.php:48docker compose run --rm wpcli wp plugin deactivate galeria-pro --skip-pluginsPlugin 'galeria-pro' deactivated.
  1. Desactívalo. --skip-plugins hace que WP-CLI no cargue los plugins, porque el roto también lo tumbaría. Sin WP-CLI vale renombrar la carpeta: docker compose exec wordpress mv wp-content/plugins/galeria-pro wp-content/plugins/galeria-pro.off.
  2. Vuelve a la versión anterior si hace falta la funcionalidad: wp plugin install galeria-pro --version=3.4.1 --force --skip-plugins y actívalo.
  3. Si la actualización cambió la base de datos, la versión anterior puede no entenderla: entonces se restaura la copia de antes de la actualización, la del apartado 5.
  4. Después: aviso al proveedor del plugin, el plugin fuera de las actualizaciones automáticas hasta que lo corrijan, y la regla de que las actualizaciones pasan por pruebas.

Siguientes pasos

  • La guía de Docker cubre la base: volúmenes, registros y qué hacer cuando un contenedor se reinicia solo.
  • La guía de PostgreSQL/MySQL/SQL Server profundiza en copias y restauraciones de bases de datos.
  • Para llevar el registro de cada prueba de restauración (qué se restauró, cuándo, cuánto tardó y si funcionó), el repositorio 32-backup-restore-test-tracker de mi GitHub sigue esa misma idea.

¿Falta algo o en tu entorno es distinto? Escríbeme y lo corrijo.

What you'll get

The corporate WordPress site running in containers on a Linux server: WordPress and its database defined in one file, data in volumes, passwords kept out of the repository, HTTPS in front, a daily backup you know how to restore because you have tried it, and the routine for when an update leaves the site blank.

Before you start

  • A server with Docker and the Compose plugin (see the Docker guide), with space for the volumes and the backups.
  • The site's name in DNS pointing at the server and a certificate (Let's Encrypt or the corporate CA's).
  • A second server or test machine to restore onto.

1. The compose file: WordPress, MariaDB and volumes

compose.yaml
services:  db:    image: mariadb:11.4    restart: unless-stopped    environment:      MARIADB_DATABASE: wordpress      MARIADB_USER: wordpress      MARIADB_PASSWORD: ${DB_PASSWORD}      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}    volumes:      - db_data:/var/lib/mysql    healthcheck:      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]      interval: 30s      retries: 5   wordpress:    image: wordpress:6.9-php8.3-apache    restart: unless-stopped    depends_on:      db:        condition: service_healthy    environment:      WORDPRESS_DB_HOST: db      WORDPRESS_DB_NAME: wordpress      WORDPRESS_DB_USER: wordpress      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}      WORDPRESS_CONFIG_EXTRA: |        define('DISALLOW_FILE_EDIT', true);    volumes:      - wp_html:/var/www/html    ports:      - "127.0.0.1:8080:80"   wpcli:    image: wordpress:cli-php8.3    profiles: ["tools"]    user: "33:33"    environment:      HOME: /tmp      WORDPRESS_DB_HOST: db      WORDPRESS_DB_NAME: wordpress      WORDPRESS_DB_USER: wordpress      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}    volumes:      - wp_html:/var/www/html volumes:  db_data:  wp_html:
  • Pinned tags, never latest: that way you know which version runs and an update is a change you decide on. Use whichever WordPress tag is current when you read this, with its PHP version.
  • The port is published on 127.0.0.1 only: WordPress is reached through the proxy, not directly.
  • wpcli sits in a profile: it does not start with docker compose up, only when you call it. It runs as user 33 because that is www-data in the Debian-based WordPress image; the WP-CLI image uses a different one by default and would cause permission problems.

2. Passwords out of git

Compose reads the ${DB_PASSWORD} variables from an .env file next to compose.yaml. That file is never pushed:

server web01 · /srv/corporate-site
cat .gitignore.envbackups/chmod 600 .envgit check-ignore -v .env.gitignore:1:.env	.env
  • The repository holds an .env.example with the variable names and no values.
  • docker compose config prints the file with the variables resolved: passwords included. Do not paste it into a ticket.
  • The MARIADB_* variables only apply when the data volume is empty, the first time. Changing the password in .env later does not change it in the database: do it with ALTER USER and then in .env.

If a password ever reached a commit, change it. Removing it from the history does not undo someone having cloned it.

3. Reverse proxy with TLS

An Nginx on the server itself terminates HTTPS and forwards to WordPress. Traefik or Caddy work just as well; what matters is the headers:

/etc/nginx/conf.d/corporate-site.conf
server {    listen 80;    server_name www.contoso.example;    return 301 https://$host$request_uri;}server {    listen 443 ssl;    server_name www.contoso.example;    ssl_certificate     /etc/letsencrypt/live/www.contoso.example/fullchain.pem;    ssl_certificate_key /etc/letsencrypt/live/www.contoso.example/privkey.pem;    client_max_body_size 64m;     location = /xmlrpc.php { deny all; }    location ~* ^/wp-content/uploads/.*\.php$ { deny all; }     location / {        proxy_pass http://127.0.0.1:8080;        proxy_set_header Host $host;        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;        proxy_set_header X-Forwarded-Proto $scheme;    }}
  • The official image already adds to wp-config.php the code that reads X-Forwarded-Proto. Without that header WordPress thinks it is on HTTP and you get redirect loops or mixed content.
  • Behind the proxy, Apache sees the proxy's IP. If a security plugin blocks by IP it needs to read X-Forwarded-For, or it will block the whole proxy.

4. Updates: core, plugins, themes and image

With the volume mounted on /var/www/html, the image only copies WordPress the first time, when the volume is empty. From then on WordPress manages itself inside the volume. So:

  • Changing the image tag updates PHP, Apache and the extensions, not the WordPress core.
  • Core, plugins and themes are updated from Dashboard › Updates or with WP-CLI.
server web01 · check what is pending
docker compose run --rm wpcli wp core check-updatedocker compose run --rm wpcli wp plugin list --update=availablename            status   update     versiongaleria-pro     active   available  3.4.1formularios-xy  active   available  2.8.0docker compose run --rm wpcli wp plugin update formularios-xy

The routine that avoids surprises:

  1. Backup (section 5) right before.
  2. Update in test first and open the pages that matter.
  3. In production, one plugin at a time, checking the site between each.
  4. Plugins and themes nobody uses, removed: deactivated, they are still code that can be attacked. Keep one default theme as a fallback.
  5. The image: change the tag in compose.yaml, docker compose pull and docker compose up -d. For a major MariaDB version jump, the MARIADB_AUTO_UPGRADE=1 variable makes the image run the table upgrade on start.

5. Backing up the database and wp-content, and the restore test

What to keep: the database (posts, pages, users, settings) and wp-content (uploads, plugins, themes). Plus compose.yaml and .env, somewhere safe.

server web01 · daily backup
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -uroot -p"$MARIADB_ROOT_PASSWORD" wordpress' | gzip > backups/wp-db-$(date +%F).sql.gzdocker compose exec -T wordpress tar -C /var/www/html -czf - wp-content > backups/wp-content-$(date +%F).tar.gzls -lh backups/-rw-r--r-- 1 root root  18M Oct 30 02:00 wp-db-2026-10-30.sql.gz-rw-r--r-- 1 root root 2.1G Oct 30 02:01 wp-content-2026-10-30.tar.gz
  • --single-transaction takes a consistent copy without locking the site. Recent MariaDB images ship mariadb-dump, not mysqldump.
  • Backups go off the server: if the machine is lost, they are lost with it.

The restore test, once a month on the test server with the same compose.yaml:

test server · restore
docker compose up -d db wordpressgunzip -c wp-db-2026-10-30.sql.gz | docker compose exec -T db sh -c 'exec mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" wordpress'docker compose exec -T wordpress tar -C /var/www/html -xzf - < wp-content-2026-10-30.tar.gz

Point the site's name at the test IP in your hosts file and check: the home page loads, you can sign in, the latest post is there, images show and the contact form sends. Write down how long it took: that is your real recovery time.

6. Basic hardening

  • xmlrpc.php blocked at the proxy (section 3), unless an application really uses it. It is the favourite door for brute-force attacks.
  • No user called admin, few administrators and all with a second factor. People who only publish get the Editor role. Review the list with wp user list --role=administrator.
  • File editing off with DISALLOW_FILE_EDIT: nobody edits PHP from the browser. If you do every update yourself with WP-CLI, DISALLOW_FILE_MODS also stops plugins being installed or updated from the dashboard.
  • wp-admin restricted to the VPN or office IPs at the proxy, leaving admin-ajax.php public, because many themes use it.
  • PHP in uploads blocked: an uploaded file must never run.
  • WAF: ModSecurity with the OWASP CRS rules at the proxy, the CDN's WAF if there is one, or a security plugin. Start in detection mode, review the false positives, then block.

7. The ticket: "the site is down after a plugin update"

Marketing updated a plugin from the dashboard. The site shows There has been a critical error on this website.

  1. What failed: the error is in the container log.
server web01 · find the error
docker compose logs --since 30m wordpress | grep -i "fatal"PHP Fatal error:  Uncaught Error: Call to undefined function galeria_pro_init() in /var/www/html/wp-content/plugins/galeria-pro/galeria-pro.php:48docker compose run --rm wpcli wp plugin deactivate galeria-pro --skip-pluginsPlugin 'galeria-pro' deactivated.
  1. Deactivate it. --skip-plugins stops WP-CLI loading plugins, because the broken one would take it down too. Without WP-CLI, renaming the folder works: docker compose exec wordpress mv wp-content/plugins/galeria-pro wp-content/plugins/galeria-pro.off.
  2. Go back to the previous version if the feature is needed: wp plugin install galeria-pro --version=3.4.1 --force --skip-plugins and activate it.
  3. If the update changed the database, the old version may not understand it: then you restore the pre-update backup from section 5.
  4. Afterwards: tell the plugin vendor, take the plugin out of automatic updates until they fix it, and keep the rule that updates go through test.

Next steps

  • The Docker guide covers the basics: volumes, logs and what to do when a container keeps restarting.
  • The PostgreSQL/MySQL/SQL Server guide goes deeper into database backups and restores.
  • To keep a record of each restore test (what was restored, when, how long it took and whether it worked), the 32-backup-restore-test-tracker repository on my GitHub follows that same idea.

Is something missing, or different in your environment? Get in touch and I'll fix it.

Tech Career Lab · newsletter

¿Te ha servido? Cada dos semanas envío una idea práctica para perfiles IT: LinkedIn, proyectos, networking y procesos de selección.Found this useful? Every two weeks I send one practical idea for IT professionals: LinkedIn, projects, networking and hiring processes.

Apúntate y llévate las 33 ideasSign up and get the 33 ideas →