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
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. wpcliestá en un profile: no arranca condocker compose up, solo cuando lo llamas. Usa el usuario 33 porque es elwww-datade 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:
cat .gitignore.envbackups/chmod 600 .envgit check-ignore -v .env.gitignore:1:.env .env- En el repositorio va un
.env.examplecon los nombres de las variables y sin valores. docker compose configmuestra 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.envdespués no la cambia en la base de datos: hay que hacerlo conALTER USERy 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:
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.phpel código que leeX-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.
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-xyLa rutina que evita sustos:
- Copia (apartado 5) justo antes.
- Actualizar primero en pruebas y abrir las páginas que importan.
- En producción, un plugin cada vez, comprobando la web entre uno y otro.
- Plugins y temas que no se usan, fuera: desactivados siguen siendo código que se puede atacar. Deja un tema por defecto como reserva.
- La imagen: cambiar la etiqueta en
compose.yaml,docker compose pullydocker compose up -d. Para un salto de versión mayor de MariaDB, la variableMARIADB_AUTO_UPGRADE=1hace 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.
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-transactionhace una copia coherente sin bloquear la web. Las imágenes recientes de MariaDB traenmariadb-dump, nomysqldump.- 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:
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.gzApunta 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_MODStambié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.
- Qué ha fallado: el error está en el registro del contenedor.
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.- Desactívalo.
--skip-pluginshace 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. - Vuelve a la versión anterior si hace falta la funcionalidad:
wp plugin install galeria-pro --version=3.4.1 --force --skip-pluginsy actívalo. - 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.
- 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
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.1only: WordPress is reached through the proxy, not directly. wpclisits in a profile: it does not start withdocker compose up, only when you call it. It runs as user 33 because that iswww-datain 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:
cat .gitignore.envbackups/chmod 600 .envgit check-ignore -v .env.gitignore:1:.env .env- The repository holds an
.env.examplewith the variable names and no values. docker compose configprints 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.envlater does not change it in the database: do it withALTER USERand 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:
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.phpthe code that readsX-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.
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-xyThe routine that avoids surprises:
- Backup (section 5) right before.
- Update in test first and open the pages that matter.
- In production, one plugin at a time, checking the site between each.
- Plugins and themes nobody uses, removed: deactivated, they are still code that can be attacked. Keep one default theme as a fallback.
- The image: change the tag in
compose.yaml,docker compose pullanddocker compose up -d. For a major MariaDB version jump, theMARIADB_AUTO_UPGRADE=1variable 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.
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-transactiontakes a consistent copy without locking the site. Recent MariaDB images shipmariadb-dump, notmysqldump.- 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:
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.gzPoint 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_MODSalso stops plugins being installed or updated from the dashboard. - wp-admin restricted to the VPN or office IPs at the proxy, leaving
admin-ajax.phppublic, 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.
- What failed: the error is in the container log.
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.- Deactivate it.
--skip-pluginsstops 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. - Go back to the previous version if the feature is needed:
wp plugin install galeria-pro --version=3.4.1 --force --skip-pluginsand activate it. - If the update changed the database, the old version may not understand it: then you restore the pre-update backup from section 5.
- 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 →