Guías · Nivel 3

WordPress en Docker: la web corporativa con Compose, copias que se restauran, bastionado y un plugin que tumba la web

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.

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.

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.

Apúntate y llévate las 33 ideas →