Autoalojar WordPress con Docker: Apache o PHP-FPM, más una caché de objetos en Redis
Todos los tutoriales de WordPress sobre Docker se detienen en "arranca". Este va más allá: versiones fijadas, una caché de objetos real con la extensión de PHP compilada, un proxy inverso que termina TLS de verdad, y los dos o tres comportamientos de la imagen oficial que te morderán el día 30 y no el primero.
Las versiones de abajo eran las vigentes cuando lo comprobé, el 2 de agosto de 2026. Fíjalas. No pongas :latest en producción.
| Componente | Etiqueta | Notas |
|---|---|---|
| WordPress | wordpress:7.0.2-php8.5-apache | o 7.0.2-php8.5-fpm-alpine |
| PHP | 8.5 | incluido en la imagen de WordPress |
| MariaDB | mariadb:12.3 | lts resuelve ahora mismo a 12.3.2 |
| Redis | redis:8.10-alpine | solo servidor, sin persistencia |
| nginx | nginx:1.30.4-alpine | solo si lo ejecutas como contenedor |
| Redis Object Cache | 2.8.0 | plugin, incluye Predis 2.4.0 |
| phpredis | 6.3.0 | opcional, ver el paso 3 |
| WP-CLI | wordpress:cli-php8.5 | sidecar, se ejecuta bajo demanda |
WordPress 7.1 llega el 19 de agosto de 2026, así que cuenta con que la parte 7.0.2 de esa etiqueta cambie pronto.
El stack
Tres contenedores hacen el trabajo. Nada es accesible desde fuera salvo el único puerto que publicas, y lo publicas solo en loopback.
[ nginx, terminating TLS ]
|
v
127.0.0.1:8080
|
===========|=================== docker
v
[ wp_app ] wordpress + php
|
| service names on wp_default
+--------+--------+
| |
[ wp_db ] [ wp_redis ]
:3306 :6379La base de datos y Redis no publican ningún puerto. Son accesibles desde los otros contenedores por nombre de servicio, db y redis, y desde ningún otro sitio.
Dos decisiones
Cómo se ejecuta PHP. Apache con mod_php en un contenedor, o PHP-FPM. Apache es una imagen, un proceso y ningún fichero de configuración extra. FPM te da un servidor web aparte que puede servir ficheros estáticos sin tocar PHP, y te cuesta una configuración de nginx que a partir de ahora mantienes tú.
Dónde se ejecuta nginx. En el host, o como un cuarto contenedor.
edge = nginx on the host edge = nginx in compose
A) apache image publish 127.0.0.1:8080 proxy_pass wordpress:80
proxy_pass to it 4 containers
3 containers <-- start here
B) fpm image publish 127.0.0.1:9000 fastcgi_pass wordpress:9000
fastcgi_pass to it 4 containers
3 containersEmpieza con A sobre un nginx del host. Es la opción con menos piezas móviles, y certbot en el host se encarga de las renovaciones sin que tengas que pensar en ello. Pasa a B si quieres caché y limitación de tasa por ubicación, o si sirves suficientes ficheros estáticos como para que mantenerlos fuera de PHP importe.
Los pasos 1 a 5 son idénticos para todas las combinaciones.
Requisitos previos
Docker Engine 27 o posterior con el plugin Compose v2. Esa es toda la lista.
Coloca el proyecto en un sitio por el que nginx pueda pasar. Si el nginx del host va a leer ./data/wordpress directamente (ruta B), /root/wp no funcionará por mucho chmod que hagas, porque /root es 700. Usa /srv/wp o /opt/wp.
sudo mkdir -p /srv/wp && cd /srv/wpPaso 1: estructura
/srv/wp/
├─ .env # secrets and sizing
├─ compose.yaml
├─ Dockerfile # adds phpredis
├─ config/
│ ├─ php.ini # PHP + opcache overrides
│ └─ nginx.conf # only if nginx runs as a container
├─ backup/
└─ data/
├─ db/ # MariaDB datadir
└─ wordpress/ # core + wp-contentmkdir -p config backup data/db data/wordpressSin directorio de datos para Redis. Más sobre eso en el paso 4.
Paso 2: .env
# Database
MARIADB_ROOT_PASSWORD=change_me_root
MARIADB_DATABASE=wordpress
MARIADB_USER=wordpress
MARIADB_PASSWORD=change_me_user
WORDPRESS_TABLE_PREFIX=wp_
# Where the stack listens on the host (loopback only)
HTTP_BIND=127.0.0.1:8080
# WordPress memory
WP_MEMORY_LIMIT=256M
WP_MAX_MEMORY_LIMIT=512M
# Redis
REDIS_MAXMEMORY=256mb
WP_REDIS_PREFIX=wpGenera las contraseñas en lugar de teclearlas:
openssl rand -base64 24Después chmod 600 .env y añádelo a .gitignore.
WP_REDIS_PREFIX importa en cuanto levantas un segundo sitio WordPress. Dos sitios que comparten un Redis sin prefijo leerán las claves de caché del otro, y los síntomas parecen cosa de fantasmas.
Paso 3: elegir un cliente de Redis
Puedes saltarte este paso. Existe porque hay dos formas de que PHP hable con Redis, y muchas guías presentan la más difícil como obligatoria.
La imagen oficial de WordPress incluye bcmath, exif, gd, intl, mysqli, zip e imagick. No incluye la extensión de Redis. Da igual, porque el plugin Redis Object Cache trae Predis, un cliente de Redis escrito en PHP puro, y lo usa automáticamente cuando la extensión no está.
phpredis (PECL) Predis (bundled)
client language C extension PHP
in stock image no, must build yes, ships with plugin
custom Dockerfile required not needed
rebuild on every yes no
upstream update
plugin support yes yes, the default fallbackUsa la imagen tal cual. Pon la caché a funcionar, abre luego la pestaña Metrics del plugin y mira los milisegundos que reporta por petición. Si ese número te molesta, vuelve y compila la extensión. En una web de presentación no notarás la diferencia. En un WooCommerce con cuarenta plugins haciendo varios cientos de llamadas a wp_cache_get por página, sí.
El coste de la imagen propia no es el Dockerfile, es que a partir de ahora reconstruyes cada vez que upstream publica un parche de seguridad de WordPress o de PHP. Ten claro lo que compras.
Si la quieres, añade un Dockerfile junto a compose.yaml.
Apache (base Debian, con PHPIZE_DEPS ya instalado):
FROM wordpress:7.0.2-php8.5-apache
RUN set -eux; \
pecl install redis-6.3.0; \
docker-php-ext-enable redis; \
rm -rf /tmp/pearFPM sobre Alpine (las herramientas de compilación no vienen preinstaladas, así que se añaden y se quitan):
FROM wordpress:7.0.2-php8.5-fpm-alpine
RUN set -eux; \
apk add --no-cache --virtual .build-deps $PHPIZE_DEPS; \
pecl install redis-6.3.0; \
docker-php-ext-enable redis; \
apk del .build-deps; \
rm -rf /tmp/pearDespués cambia image: por build: en el paso siguiente. El plugin detecta la extensión por su cuenta; también puedes fijar la elección con define('WP_REDIS_CLIENT', 'phpredis'); o 'predis'.
Paso 4: config/php.ini
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300
max_input_time = 300
max_input_vars = 3000
; the base image sets 4000 files and revalidate_freq=2,
; both too tight for a plugin-heavy production site
opcache.memory_consumption = 192
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 60Un patrón habitual es pasar esto como variables de entorno y reescribirlas en un fichero ini sobrescribiendo el command del contenedor. Sáltatelo. Sobrescribir command significa que ahora mantienes una línea de shell que tiene que terminar en docker-entrypoint.sh apache2-foreground, y una errata ahí te cuesta el entrypoint que descomprime WordPress y escribe wp-config.php. Un fichero montado es una línea de YAML y sobrevive a cada actualización de imagen.
El prefijo zz- en el destino del montaje hace que PHP lo cargue el último, así que gana frente al propio opcache-recommended.ini de la imagen.
Paso 5: compose.yaml
Esta es la ruta A: Apache, publicado en loopback para que un nginx del host haga de proxy.
name: wp
services:
db:
image: mariadb:12.3
container_name: wp_db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?missing in .env}
MARIADB_DATABASE: ${MARIADB_DATABASE:-wordpress}
MARIADB_USER: ${MARIADB_USER:-wordpress}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:?missing in .env}
MARIADB_AUTO_UPGRADE: "1"
volumes:
- ./data/db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
redis:
image: redis:8.10-alpine
container_name: wp_redis
restart: unless-stopped
command: >
redis-server
--save ""
--appendonly no
--maxmemory ${REDIS_MAXMEMORY:-256mb}
--maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
wordpress:
image: wordpress:7.0.2-php8.5-apache
# only if you compiled phpredis in step 3:
# build:
# context: .
# dockerfile: Dockerfile
# image: wp-wordpress:local
container_name: wp_app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${HTTP_BIND:-127.0.0.1:8080}:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-wordpress}
WORDPRESS_DB_USER: ${MARIADB_USER:-wordpress}
WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:?missing in .env}
WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
WORDPRESS_CONFIG_EXTRA: |
define('WP_REDIS_HOST', 'redis');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_PREFIX', '${WP_REDIS_PREFIX:-wp}');
define('WP_REDIS_DATABASE', 0);
define('WP_REDIS_TIMEOUT', 1);
define('WP_REDIS_READ_TIMEOUT', 1);
define('WP_CACHE', true);
define('WP_MEMORY_LIMIT', '${WP_MEMORY_LIMIT:-256M}');
define('WP_MAX_MEMORY_LIMIT', '${WP_MAX_MEMORY_LIMIT:-512M}');
define('DISALLOW_FILE_EDIT', true);
define('FS_METHOD', 'direct');
volumes:
- ./data/wordpress:/var/www/html
- ./config/php.ini:/usr/local/etc/php/conf.d/zz-wordpress.ini:ro
wpcli:
image: wordpress:cli-php8.5
profiles: [tools]
user: "33:33"
depends_on:
db:
condition: service_healthy
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-wordpress}
WORDPRESS_DB_USER: ${MARIADB_USER:-wordpress}
WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:?missing in .env}
WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
volumes:
- ./data/wordpress:/var/www/htmlSin bloque networks:. Compose pone cada servicio en una red de proyecto llamada wp_default y da a cada uno un nombre DNS igual a su nombre de servicio, que es la razón de que WORDPRESS_DB_HOST sea db:3306. Declarar redes a mano no aporta nada aquí.
Cinco cosas que conviene señalar.
La dirección de escucha no es opcional. Escribir "8080:80" publica en todas las interfaces, y Docker inserta sus propias reglas de iptables por delante de las tuyas, así que ufw te dirá tan tranquilo que el puerto 8080 está bloqueado mientras medio internet lee tu WordPress por HTTP plano. 127.0.0.1:8080:80 es el arreglo.
${VAR:?message} aborta docker compose up con tu mensaje cuando falta una variable. Sin eso, una errata en .env te deja una base de datos con contraseña vacía.
Redis no tiene volumen, --save "", --appendonly no. Una caché de objetos es dato derivado. Persistirla te compra una caché caliente tras un reinicio y te cuesta escrituras a disco por fork para siempre. Una caché fría se rellena en segundos. Si más adelante usas Redis para sesiones o para una cola, ese cálculo cambia.
--maxmemory-policy allkeys-lru es la opción importante. Redis usa noeviction por defecto, lo que significa que en cuanto la caché se llena las escrituras empiezan a fallar y WordPress empieza a lanzar errores de caché en vez de descartar en silencio las claves más frías.
Los healthchecks corren cada 10s, no cada 120s. depends_on: service_healthy espera a que una comprobación pase, así que un intervalo lento se traduce directamente en un arranque lento. Dos minutos con el stack pareciendo roto no es ninguna virtud.
Si arrastraste un bloque volumes: de primer nivel declarando db_data y compañía, bórralo. Esos volúmenes con nombre no se usan cuando montas ./data/db como bind mount.
Paso 6: nginx en el host
Instala nginx y certbot, y luego coloca un vhost:
sudo apt install nginx certbot python3-certbot-nginx# /etc/nginx/sites-available/example.com
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
client_max_body_size 64m; # keep in sync with post_max_size
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
}
}sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com -d www.example.comX-Forwarded-Proto es la cabecera que importa. WordPress decide si emite URLs http:// o https:// a partir de ella, y el wp-config de la imagen oficial ya la lee (ver el paso 8). Quita esa cabecera y tendrás avisos de contenido mixto, o un bucle de redirecciones.
Que client_max_body_size y post_max_size no coincidan es la causa más común de "mi vídeo de 30 MB muere al 90%". nginx rechaza el cuerpo de la petición antes de que PHP llegue a verlo, así que subir solo el límite de PHP no sirve de nada.
Certbot reescribe el fichero para añadir un bloque 443 y una redirección, e instala un temporizador de renovación. Compruébalo con systemctl list-timers | grep certbot.
Paso 6, ruta B: PHP-FPM
Dos cambios en el fichero compose. Apunta el build al Dockerfile de FPM y publica FastCGI en vez de HTTP:
wordpress:
image: wordpress:7.0.2-php8.5-fpm-alpine
# or build: from the fpm-alpine Dockerfile in step 3
container_name: wp_app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "127.0.0.1:9000:9000" # FastCGI, loopback only, no exceptions
environment:
# unchanged from route A
...
volumes:
- ./data/wordpress:/var/www/html
- ./config/php.ini:/usr/local/etc/php/conf.d/zz-wordpress.ini:roEl puerto 9000 habla FastCGI, no HTTP. FastCGI no tiene autenticación ni TLS, y cualquier cosa que llegue a él puede ejecutar PHP arbitrario. Si alguna vez te descubres escribiendo 9000:9000 sin el prefijo de loopback, para.
Ahora el nginx del host sirve él mismo los ficheros estáticos y solo pasa PHP al contenedor:
# /etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com;
root /srv/wp/data/wordpress; # the HOST path
index index.php;
client_max_body_size 64m;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
try_files $uri =404;
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
# the container sees these files at /var/www/html, not at the host path
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT /var/www/html;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_read_timeout 300;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
location ~* \.(js|css|png|jpe?g|gif|ico|svg|webp|avif|woff2?)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
try_files $uri =404;
}
# never execute PHP that arrived through the media uploader
location ^~ /wp-content/uploads/ {
location ~ \.php$ { deny all; }
}
location = /favicon.ico { log_not_found off; access_log off; }
location = /robots.txt { log_not_found off; access_log off; allow all; }
location ~ /\. { deny all; }
location = /xmlrpc.php { deny all; } # drop this line if you use the WP mobile app
}Esas dos sobrescrituras de fastcgi_param son todo el truco, y saltárselas es la razón por la que este montaje se suele abandonar. nginx calcula SCRIPT_FILENAME a partir de su propio root, que es /srv/wp/data/wordpress. PHP-FPM busca entonces esa ruta dentro del contenedor, donde no existe, y devuelve "Primary script unknown" o una página en blanco sin nada útil en el log de nginx. Los dos sistemas de ficheros son los mismos ficheros en rutas distintas, y solo tú conoces la correspondencia.
Dos notas menores sobre esta ruta. try_files $uri =404 dentro del location de PHP cierra el agujero de ejecución de código vía path-info que las configuraciones estándar de 2013 todavía arrastran. Y la detección de HTTPS funciona distinto aquí: no hay proxy inverso, así que no hay X-Forwarded-Proto. El fastcgi_params de Debian pasa HTTPS=on directamente en cuanto certbot ha montado el bloque TLS, y WordPress lo recoge por su cuenta.
Paso 6, alternativa: nginx como contenedor
Usa esto si el host no tiene nginx y prefieres mantenerlo todo en Docker. Añade un cuarto servicio y deja de publicar un puerto desde wordpress:
nginx:
image: nginx:1.30.4-alpine
container_name: wp_nginx
restart: unless-stopped
depends_on: [wordpress]
ports:
- "80:80"
- "443:443"
volumes:
- ./data/wordpress:/var/www/html:ro # only needed for the fpm variant
- ./config/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./config/certs:/etc/nginx/certs:ro # if you have certs alreadyCon la imagen de Apache, config/nginx.conf es la configuración de proxy del paso 6 con proxy_pass http://wordpress:80; en vez de 127.0.0.1:8080. Con la imagen FPM, es la configuración FastCGI con fastcgi_pass wordpress:9000;, root /var/www/html; y sin reasignación de rutas, porque dentro de Docker ambos contenedores coinciden en la ruta.
Sé realista con TLS aquí. Un nginx en contenedor tiene sentido cuando ya tienes certificados en disco, o cuando el sitio es interno y va por HTTP plano, o cuando algo por delante termina TLS por ti. Hacer que la renovación automática de Let's Encrypt funcione dentro de este contenedor implica un sidecar de certbot, un webroot compartido y un hook de recarga. Si ese no es un proyecto que te apetezca, usa el nginx del host, o cambia este servicio por Caddy o Traefik, que hacen ACME por su cuenta.
Paso 7: primer arranque
docker compose up -d
docker compose logs -f wordpressEjecuta docker compose build primero solo si elegiste la imagen propia en el paso 3.
Estás buscando dos líneas:
WordPress not found in /var/www/html - copying now...
No 'wp-config.php' found in /var/www/html, but 'WORDPRESS_...' variables supplied; copying ...Más o menos así se encadena el arranque:
t=0s db starting health: starting
t~20s db healthy healthcheck.sh --connect passes
t~20s redis healthy
t~21s wordpress starts unpack core -> generate wp-config.phpComprueba que el stack responde en loopback antes de meter a nginx. Es la forma más rápida de averiguar cuál de las dos mitades está rota:
curl -I http://127.0.0.1:8080 # route A
# HTTP/1.1 302 Found
# Location: http://127.0.0.1:8080/wp-admin/install.phpDespués abre el dominio y termina la instalación de cinco minutos.
Paso 8: qué escribe la imagen en wp-config.php
Tres hechos que conviene saber antes de necesitarlos.
Las sales se generan una sola vez. Las claves de autenticación y las sales salen de /dev/urandom en el momento en que se crea el fichero, y se escriben en él literalmente. No se leen del entorno en cada arranque. Para gestionarlas tú, define WORDPRESS_AUTH_KEY, WORDPRESS_SECURE_AUTH_KEY, WORDPRESS_LOGGED_IN_KEY, WORDPRESS_NONCE_KEY, WORDPRESS_AUTH_SALT, WORDPRESS_SECURE_AUTH_SALT, WORDPRESS_LOGGED_IN_SALT y WORDPRESS_NONCE_SALT antes del primer arranque. Cambiarlas más tarde cierra la sesión de todo el mundo, que es justo lo que quieres después de una brecha.
Todo lo demás se lee en vivo. WORDPRESS_CONFIG_EXTRA y las variables WORDPRESS_DB_* pasan por un ayudante getenv_docker() en cada petición, y el bloque extra se ejecuta con eval(). Así que editar WORDPRESS_CONFIG_EXTRA y lanzar docker compose up -d surte efecto de inmediato. No necesitas borrar wp-config.php.
El arreglo para el proxy inverso ya está ahí. No hace falta añadirlo:
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
$_SERVER['HTTPS'] = 'on';
}Si aun así tienes una redirección HTTPS infinita, el problema está más arriba: nginx no está enviando la cabecera.
Paso 9: activar la caché de objetos
Tres comandos, sobre la imagen tal cual:
docker compose run --rm wpcli plugin install redis-cache --activate
docker compose run --rm wpcli redis enable
docker compose run --rm wpcli redis statusredis status te dice qué cliente ha elegido:
Status: Connected
Client: Predis (v2.4.0)
Drop-in: ValidClient: PhpRedis (v6.3.0) significa que la extensión está presente y en uso. Cualquiera de las dos líneas es una caché que funciona. Confirma la extensión por separado con docker compose exec wordpress php -m | grep -x redis, que no imprime nada en la imagen tal cual, como es de esperar.
redis enable deja wp-content/object-cache.php en su sitio. Ese fichero es todo el mecanismo. Sin él, WP_CACHE y las constantes WP_REDIS_* no hacen absolutamente nada. Instalar y activar el plugin no basta por sí solo; el drop-in tiene que existir, que es también la razón de que un fallo de escritura en wp-content se manifieste como una caché que no hace nada en silencio.
El uid del servicio wpcli no es decoración. La imagen de CLI está basada en Alpine, donde www-data es el uid 82. La imagen de WordPress con Apache está basada en Debian, donde www-data es el uid 33. Ejecuta WP-CLI con el uid equivocado y cada fichero que escriba quedará ilegible para PHP. Ajústalo a tu imagen base:
wordpress:...-apache Debian www-data = 33 -> user: "33:33"
wordpress:...-fpm Debian www-data = 33 -> user: "33:33"
wordpress:...-fpm-alpine Alpine www-data = 82 -> user: "82:82"Comprueba que las claves están llegando a Redis, luego carga el sitio unas cuantas veces y mira cómo suben los números:
docker compose exec redis redis-cli info keyspace
# db0:keys=412,expires=7,avg_ttl=0
docker compose exec redis redis-cli info stats | grep keyspace
# keyspace_hits:18342
# keyspace_misses:611Así queda el recorrido de una petición cuando funciona:
request
|
v
[ nginx ] -> [ apache | php-fpm ]
|
v
[ PHP: wp_cache_get ]
|
+-- HIT --> render from Redis, ~2 SQL queries
|
+-- MISS --> MariaDB --> wp_cache_set --> renderUna tasa de aciertos por debajo del 90% tras un día de tráfico suele indicar un problema de desalojo, no un problema de WordPress. Mira si used_memory se ha quedado clavado en maxmemory y sube REDIS_MAXMEMORY.
Una caché de objetos no es una caché de páginas. Quita consultas repetidas a la base de datos de cada petición, incluidas las de usuarios identificados y las del administrador. No evita que WordPress arranque PHP. La caché de página completa corresponde a nginx o a un plugin, y es una decisión aparte.
Paso 10: copias de seguridad
No empaquetes con tar un datadir de MariaDB en marcha. Se está escribiendo en ./data/db mientras se crea el archivo, y la copia que recuperes puede ser consistente o no. Vuelca la base de datos y archiva los ficheros por separado.
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
source .env
stamp=$(date +%Y%m%d-%H%M)
docker compose exec -T db \
mariadb-dump --single-transaction --quick --no-tablespaces \
-u root -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE" \
| gzip > "backup/db-$stamp.sql.gz"
tar -czf "backup/files-$stamp.tar.gz" -C data/wordpress wp-content
find backup -name '*.gz' -mtime +14 -delete--single-transaction te da una instantánea consistente sobre InnoDB sin bloquear a quien escribe. En MariaDB 11 y posteriores el binario es mariadb-dump; mysqldump sigue existiendo como enlace simbólico, pero no para siempre.
Restaurar:
gunzip -c backup/db-20260802-1130.sql.gz | \
docker compose exec -T db mariadb -u root -p"$MARIADB_ROOT_PASSWORD" wordpressSolo merece la pena archivar wp-content. Los ficheros del núcleo vienen de la imagen, y wp-config.php es reproducible desde tu fichero compose, salvo por esas sales. Haz una copia de las sales una vez.
Paso 11: actualizar
El que sorprende a la gente. Mira la guarda del entrypoint:
if [ ! -e index.php ] && [ ! -e wp-includes/version.php ]; then
echo >&2 "WordPress not found in $PWD - copying now..."Los ficheros del núcleo se copian desde la imagen solo cuando el directorio está vacío. Una vez que ./data/wordpress contiene una instalación, hacer pull de wordpress:7.1-php8.5-apache te da la compilación de PHP más nueva, el Apache más nuevo y las extensiones más nuevas, y deja tus ficheros del núcleo de WordPress exactamente donde estaban.
Así que las actualizaciones se parten en dos:
Image tag -> PHP, Apache/FPM, extensions, base OS
docker compose pull && docker compose up -d
(add `docker compose build --pull` if you use the custom image)
WP core -> dashboard, or:
docker compose run --rm wpcli core update
docker compose run --rm wpcli core update-db
docker compose run --rm wpcli plugin update --allLas versiones mayores de MariaDB necesitan MARIADB_AUTO_UPGRADE: "1", ya presente en el fichero compose de arriba. Ejecuta mariadb-upgrade cuando detecta un datadir más antiguo. Haz un volcado antes de todos modos, y avanza de una versión mayor cada vez.
Resolución de problemas
| Síntoma | Causa probable |
|---|---|
| "Error establishing a database connection" en el primer arranque | ./data/db se inicializó con otra contraseña en una ejecución anterior. La imagen de MariaDB solo aplica credenciales a un datadir vacío. Bórralo o restablece la contraseña a mano |
| 502 Bad Gateway desde el nginx del host | no hay nada escuchando en 127.0.0.1:8080. Revisa docker compose ps y ss -ltnp | grep 8080 |
| "Primary script unknown", ruta B | SCRIPT_FILENAME sigue apuntando a la ruta del host. Sobrescríbelo a /var/www/html$fastcgi_script_name |
| 403 de nginx en ficheros estáticos, ruta B | nginx no puede atravesar hasta ./data/wordpress. Saca el proyecto de un directorio padre con 700 |
El sitio carga pero todas las URLs son http:// | nginx no está poniendo X-Forwarded-Proto |
| Redirección HTTPS infinita | la misma causa que la anterior |
| El límite de subida sigue mostrando 2M | el ini no está montado, o está montado fuera de /usr/local/etc/php/conf.d/. Revisa docker compose exec wordpress php -i | grep upload_max |
| La subida muere a medio camino | client_max_body_size en nginx es menor que post_max_size en PHP |
| Pantalla en blanco tras activar la caché de objetos | wp-content/object-cache.php obsoleto. Bórralo y vuelve a hacer wpcli redis enable |
| Plugin activo, pero nada en Redis | el drop-in nunca se escribió. redis status dirá que el drop-in no es válido. Comprueba que se puede escribir en wp-content |
redis status dice "Not connected" | WP_REDIS_HOST incorrecto, o los contenedores están en proyectos de Compose distintos |
Construiste la imagen propia pero redis status sigue diciendo Predis | el contenedor sigue ejecutando la imagen antigua. docker compose up -d --force-recreate wordpress |
| La instalación de plugins pide credenciales FTP | FS_METHOD no está en direct, o el usuario de PHP no puede escribir en wp-content |
| Imagen nueva descargada, versión de WordPress sin cambios | es lo esperado. Actualiza el núcleo con WP-CLI o desde el escritorio |
| La tasa de aciertos de caché se queda cerca del 60% | Redis está en maxmemory y desalojando. Sube REDIS_MAXMEMORY |
Qué hacer después
Pon el script de copia de seguridad del paso 10 en una entrada de cron y prueba la restauración en un directorio de pruebas antes de necesitarla. Después mide: instala Query Monitor, anota el número de consultas de tu página más lenta con la caché de objetos desactivada, actívala y vuelve a mirar. Ese número es la única prueba de que algo de esto funcionó.
Después de eso, el hueco que merece la pena cerrar es enterarte de que la máquina va mal antes de que te lo diga un visitante. Prometheus, Node Exporter y Grafana lo monta en el mismo tipo de servidor único, y solo el panel de Node Exporter ya te dirá si Redis está bien dimensionado.
El autoalojamiento es la mayor parte de lo que hago: más de 30 plataformas de código abierto desplegadas y mantenidas para clientes durante la última década. Si prefieres no gestionar esto tú, escríbeme.