Sauvegarder WordPress, la base et les fichiers, et prouver que le site redémarre¶
Cette page monte la chaîne entière : ce qu'une sauvegarde WordPress doit contenir, le script qui produit les deux morceaux et les envoie sur S3, la restauration faite une fois à la main jusqu'au site qui répond, puis la même vérification rejouée chaque nuit sans personne.
Une sauvegarde WordPress, c'est deux choses¶
WordPress range ses données à deux endroits, et une sauvegarde qui n'en prend qu'un ne restaure rien d'utilisable.
- La base MySQL contient les articles, les pages, les commentaires, les comptes, les réglages du site et la configuration de chaque extension.
wp-contentcontient les médias téléversés (uploads), les thèmes et les extensions.
Avec la base seule, chaque article référence des images qui n'existent plus et
le thème actif est introuvable : WordPress démarre, mais sur un site vide de sa
présentation. Avec wp-content seul, vous avez des fichiers que rien ne
rattache à un article ou à un auteur. Il faut les deux, et il faut savoir que
les deux sont là.
Ce qu'on peut laisser dehors :
- le cœur de WordPress —
wp-admin,wp-includeset les fichiers PHP de la racine. C'est un téléchargement public : on le récupère à la version qu'on veut, et il n'apporte rien à l'archive ; - les caches —
wp-content/cacheet les fichiers produits par une extension d'optimisation. Ils se régénèrent, et ils grossissent l'archive sans rien ajouter à la preuve.
Reste wp-config.php, qui n'est ni le cœur ni vos données.
Le cas de wp-config.php¶
Ce fichier contient deux choses de nature différente.
- Les identifiants de la base :
DB_NAME,DB_USER,DB_PASSWORD,DB_HOST. Ils ne valent que pour le serveur où le fichier vit, et vous les avez normalement ailleurs. - Les clés de salage :
AUTH_KEY,SECURE_AUTH_KEY,NONCE_SALTet leurs voisines. Elles signent les cookies de session. Les perdre déconnecte tout le monde une fois, et c'est tout ; les regénérer est une opération normale.
Le sauvegarder vous épargne une demi-heure le jour de la restauration, et met le mot de passe de votre base de production dans une archive qui part sur un stockage distant. Le laisser dehors vous oblige à réécrire quatre lignes, et c'est le choix à préférer si l'archive quitte votre réseau. Dans les deux cas, gardez une trace de la version de WordPress et de la liste des extensions actives : c'est ce qui manque le plus souvent le jour où on restaure.
Le script de sauvegarde¶
Deux fichiers, produits côte à côte : le dump de la base et l'archive de
wp-content.
#!/bin/bash
set -euo pipefail
ts=$(date +%Y%m%d_%H%M%S)
dest=s3://sauvegardes-monsite/wordpress
mysqldump -h db.interne -u wordpress --single-transaction --routines --triggers \
wordpress | gzip > "/backups/wordpress_${ts}.sql.gz"
tar -czf "/backups/.wp-content_${ts}.tar.gz" -C /var/www/html wp-content
mv "/backups/.wp-content_${ts}.tar.gz" "/backups/wp-content_${ts}.tar.gz"
aws s3 cp "/backups/wordpress_${ts}.sql.gz" "${dest}/mysql/"
aws s3 cp "/backups/wp-content_${ts}.tar.gz" "${dest}/files/"
--single-transaction prend le dump dans une transaction unique : les tables
InnoDB de WordPress sortent cohérentes entre elles sans que le site soit
verrouillé pendant l'opération. --routines --triggers ajoute ce que
mysqldump laisse dehors par défaut.
Le mot de passe n'est pas sur la ligne de commande : il est lu dans la variable
MYSQL_PWD ou dans un fichier ~/.my.cnf. Un mot de passe passé en argument
est visible dans la liste des processus de la machine, pour tout le monde.
Sans pipefail, un dump raté ressort en succès
Dans mysqldump | gzip, le shell ne regarde que le code de sortie du
dernier maillon. gzip réussit très bien à compresser une sortie vide, donc
le script rend 0 et le cron est content. set -o pipefail — inclus dans le
set -euo pipefail ci-dessus — fait échouer la ligne entière dès que
mysqldump échoue. C'est la première cause de sauvegardes vides qui passent
au vert pendant des mois.
L'archive est écrite sous un nom caché, puis renommée. Ce n'est pas de la
coquetterie : pendant les minutes que dure le tar, le fichier est déjà le plus
récent du répertoire, et quiconque lit « la sauvegarde la plus récente » lit une
archive tronquée. Le mv est atomique, donc le fichier apparaît sous son nom
définitif seulement quand il est complet.
Gardez l'horodatage dans les deux noms, et gardez les deux morceaux séparés : ils se restaurent séparément, et une archive unique qui contient tout oblige à tout déplier pour vérifier une moitié. Pour la rétention, une règle de cycle de vie sur le bucket supprime les objets de plus de N jours sans script à maintenir.
Restaurer une fois, à la main¶
Une sauvegarde n'est prouvée que restaurée. Faites-le une fois, entièrement, sur un poste jetable — c'est aussi la procédure que vous suivrez le jour où ça compte.
D'abord la base, dans un MySQL neuf :
docker network create wp-essai
docker run -d --name wp-db --network wp-essai \
-e MYSQL_ROOT_PASSWORD=essai -e MYSQL_DATABASE=wordpress mysql:8.0
until docker exec wp-db mysqladmin ping -h 127.0.0.1 -u root -pessai --silent; do sleep 2; done
gunzip -c /backups/wordpress_20260918_010000.sql.gz \
| docker exec -i wp-db mysql -u root -pessai wordpress
Un dump d'une seule base ne crée pas la base
mysqldump wordpress, comme ci-dessus, ne contient ni CREATE DATABASE ni
USE : la base doit exister avant, et être nommée sur la ligne de commande
du client. C'est le rôle du MYSQL_DATABASE=wordpress et du wordpress
final. Avec mysqldump --databases wordpress, le dump porte ses propres
CREATE DATABASE et USE, et se rejoue sans nommer de base.
Puis les fichiers, et un WordPress démarré dessus :
mkdir -p /essai
tar -xzf /backups/wp-content_20260918_010000.tar.gz -C /essai
docker run -d --name wp-site --network wp-essai -p 8080:80 \
-e WORDPRESS_DB_HOST=wp-db \
-e WORDPRESS_DB_NAME=wordpress \
-e WORDPRESS_DB_USER=root \
-e WORDPRESS_DB_PASSWORD=essai \
-v /essai/wp-content:/var/www/html/wp-content \
wordpress:6-apache
Le conteneur installe le cœur de WordPress lui-même : c'est la démonstration
qu'il n'avait pas à être dans la sauvegarde. Seul wp-content vient de votre
archive, et la base vient de votre dump.
Reste à demander au site s'il répond :
until curl -sf -o /dev/null http://localhost:8080/; do sleep 2; done
curl -s http://localhost:8080/wp-json/wp/v2/posts | head -c 400
Le code 200 sur la page d'accueil prouve seulement que le conteneur a démarré : une installation neuve répond 200 elle aussi. Ce qui compte, c'est qu'un de vos articles revienne par l'API REST — là, toute la chaîne est prouvée d'un coup : le dump, la base, l'application, le HTTP.
Prenez le temps que ça a pris. C'est votre durée de restauration réelle, la
seule à comparer au délai que vous avez promis. Puis détruisez tout :
docker rm -f wp-site wp-db et docker network rm wp-essai.
Ce qu'on vérifie dans une base restaurée¶
Le dump s'est rejoué sans erreur ne veut pas dire que vos données sont là. Quatre questions, dans cet ordre :
select count(*) from wp_users;
select count(*) from wp_posts where post_status = 'publish' and post_type = 'post';
select count(*) from wp_options where option_name in ('siteurl', 'home') and option_value <> '';
select max(post_date) from wp_posts where post_status = 'publish';
- Un compte peut encore se connecter. Zéro ligne dans
wp_users, c'est un site restauré que personne n'administre. - Les articles publiés sont revenus. C'est le nombre qui distingue un site
restauré d'une installation neuve : une installation neuve a déjà toutes ses
tables
wp_, et aucun de vos articles. - Le site a une adresse.
siteurlethomedoivent être présentes et non vides, donc deux lignes exactement. Sans elles, WordPress sert un site que personne n'atteint, aussi complète que soit la restauration. - La donnée est récente. La date du dernier article publié doit ressembler à celle que vous attendez, pas à celle du mois dernier. C'est ce qui attrape une sauvegarde qui a cessé d'être produite.
wp_ est un défaut, pas une règle : un site qui a changé son préfixe a besoin
du sien dans les quatre requêtes, sinon elles interrogent des tables qui
n'existent pas. Voir
le préfixe des tables.
Côté fichiers, trois repères et un volume :
ls -d /essai/wp-content/themes /essai/wp-content/plugins /essai/wp-content/uploads
find /essai/wp-content/uploads -type f | wc -l
du -sh /essai/wp-content/uploads
Une arborescence parfaitement formée avec un uploads vide est le résultat
habituel d'un tar sur un chemin exclu, ou d'un montage absent au moment de la
sauvegarde. Le nombre de fichiers et la taille totale sont ce qui le voit ;
comparez-les à l'ordre de grandeur de la production, pas à un chiffre exact.
Enfin, un média témoin, demandé au site restauré plutôt que lu sur le disque — prenez l'adresse d'une image de votre article le plus récent :
curl -sS -o /dev/null -w '%{http_code}\n' \
http://localhost:8080/wp-content/uploads/2026/09/photo.jpg
Un 404 ici, avec une base pleine et un site qui répond, c'est exactement le cas que la moitié des sauvegardes WordPress ne voit jamais.
Automatiser cette vérification¶
Ce qui précède coûte une heure ou deux, à chaque fois. C'est pour cette raison que ces tests, faits à la main, finissent par ne plus être faits.
RestoreProof rejoue ces étapes en tâche planifiée, sur votre infrastructure : un runner récupère la sauvegarde, la restaure dans un conteneur jetable, pose les mêmes questions que ci-dessus, détruit tout, et signe le résultat. Les données ne sortent pas de chez vous.
Déclarez d'abord deux sources — le dump et l'archive n'ont ni le même
préfixe ni le même motif de fichier : wordpress_*.sql.gz d'un côté,
wp-content_*.tar.gz de l'autre, avec la stratégie le plus récemment modifié
sur les deux. Les clés d'accès ne se saisissent pas : le plan porte une
référence, env://AWS_ACCESS_KEY_ID, que le runner résout dans son propre
environnement. Voir
les références de secrets.
La base et les fichiers font ensuite deux plans, pas un. Un plan rouge vous dit alors lequel des deux morceaux est en cause sans que vous ayez à lire un journal, et les deux ne répondent pas à la même question : le premier dit « ma base est restaurable », le second dit « mes médias sont là ». C'est la forme que prennent toutes les applications qui se sauvegardent en deux morceaux, décrite dans la recette des applications auto-hébergées.
Ensemble, ces deux plans sont la manipulation que vous venez de faire à la main, ligne pour ligne :
| À la main | Dans le plan |
|---|---|
aws s3 cp du dump depuis le bucket |
fetch, qui prend le fichier le plus récent |
gunzip -c |
unpack, au format gzip |
docker run mysql:8.0 |
start_sandbox |
mysql -u root … wordpress |
restore_mysql, avec database: wordpress |
| les quatre requêtes | une sonde mysql par question |
tar -xzf de l'archive |
unpack, au format tar.gz, dans le second plan |
ls des trois répertoires |
une sonde filesystem-canary |
docker rm -f |
le nettoyage, toujours exécuté |
Aller jusqu'au site qui répond demande un pas de plus : un second bac à sable
qui démarre WordPress sur la base restaurée, et une sonde http qui interroge
son API REST. Les deux bacs à sable portent les alias sandbox et sandbox-2
dans l'ordre où ils apparaissent dans le plan. C'est décrit dans
aller jusqu'au site qui répond.
Le seul ajout est max_age, sur les deux sources, et c'est la seule
vérification qu'une restauration ne peut pas déduire du contenu : elle fait
échouer l'exécution quand le fichier le plus récent trouvé à la source est plus
vieux que le délai donné. 26h laisse passer une sauvegarde nocturne, et refuse
une sauvegarde qui a manqué une nuit.
La version du bac à sable
mysql:8.0 doit reprendre la version majeure de votre serveur. Un dump pris
sur un serveur plus récent ne se rejoue pas toujours sur un serveur plus
ancien. Pour un site hébergé sur MariaDB, le bac à sable est une image
mariadb, et le reste du plan ne change pas.
Les seuils ne se recopient pas depuis cette page. Un essai restaure votre sauvegarde, compte ce qu'elle contient réellement, et propose chaque seuil sous la valeur mesurée. Voir les seuils ne se comptent pas à la main.
Chaque exécution laisse un rapport horodaté, signé, qui nomme la sauvegarde éprouvée et ce que chaque sonde a mesuré.

Il reste à choisir une fréquence. Chaque nuit place l'exécution à 2 h du matin : votre script de sauvegarde tourne à 1 h, les deux fichiers ont donc une heure quand ils sont éprouvés. L'autre déclenchement possible est un appel HTTP à la fin de ce script — le test porte alors exactement sur les fichiers qui viennent d'être produits. Comme il y a deux plans, l'historique montre deux lignes par nuit, et c'est là que se voit celui des deux qui casse.

Ce que cette chaîne ne prouve pas¶
Elle prouve qu'un dump récent se rejoue dans un MySQL neuf, que la base restaurée contient vos comptes, vos articles publiés et l'adresse du site, et que l'archive contient l'arborescence et le volume de médias attendus.
Elle ne prouve pas que les deux moitiés viennent du même instant. Le dump et l'archive sont produits l'un après l'autre : un média téléversé entre les deux est référencé dans la base sans être dans l'archive. L'écart est de quelques minutes, mais il existe, et il se réduit en rapprochant les deux commandes, pas en testant davantage.
Elle ne prouve pas non plus que chaque extension redémarre : une extension qui attend une table hors préfixe, une licence ou un service externe peut très bien échouer sur une base par ailleurs complète. Et elle ne dit rien de ce qui a été publié depuis le dernier dump — cet écart, c'est votre RPO, et il se règle avec la fréquence des sauvegardes, pas avec les tests.
Enfin, si vous avez choisi de laisser wp-config.php dehors, la restauration
réelle demande de le réécrire. Ce geste-là n'est dans aucun des deux plans :
gardez-le dans votre procédure écrite.
Les plans complets¶
À coller dans l'éditeur de plan, vue JSON (expert), en remplaçant les identifiants de source et le préfixe des tables.
Le plan de la base :
{
"steps": [
{
"type": "fetch",
"name": "Récupérer le dump WordPress",
"config": {
"source": "source_profile",
"source_id": "<identifiant de votre source base>",
"max_age": "26h",
"output": "wordpress.sql.gz"
}
},
{
"type": "unpack",
"name": "Décompresser",
"config": { "format": "gzip", "input": "wordpress.sql.gz", "output": "wordpress.sql" }
},
{
"type": "start_sandbox",
"name": "Démarrer MySQL",
"config": {
"image": "mysql:8.0",
"env": { "MYSQL_DATABASE": "wordpress" }
}
},
{
"type": "restore_mysql",
"name": "Restaurer la base",
"config": {
"database": "wordpress",
"input": "wordpress.sql"
}
}
],
"probes": [
{
"name": "wp_admin",
"type": "mysql",
"timeout": 60,
"config": {
"RESTOREPROOF_MYSQL_TABLE": "wp_users",
"RESTOREPROOF_MYSQL_ASSERT": "gte",
"RESTOREPROOF_MYSQL_EXPECTED_ROWS": "1"
}
},
{
"name": "wp_content",
"type": "mysql",
"timeout": 60,
"config": {
"RESTOREPROOF_MYSQL_TABLE": "wp_posts WHERE post_status = 'publish' AND post_type = 'post'",
"RESTOREPROOF_MYSQL_ASSERT": "gte",
"RESTOREPROOF_MYSQL_EXPECTED_ROWS": "12"
}
},
{
"name": "wp_site_url",
"type": "mysql",
"timeout": 60,
"config": {
"RESTOREPROOF_MYSQL_TABLE": "wp_options WHERE option_name IN ('siteurl','home') AND option_value <> ''",
"RESTOREPROOF_MYSQL_ASSERT": "eq",
"RESTOREPROOF_MYSQL_EXPECTED_ROWS": "2"
}
},
{
"name": "wp_orphans",
"type": "mysql",
"timeout": 60,
"config": {
"RESTOREPROOF_MYSQL_TABLE": "wp_posts p LEFT JOIN wp_users u ON u.ID = p.post_author WHERE p.post_author <> 0 AND u.ID IS NULL",
"RESTOREPROOF_MYSQL_ASSERT": "zero"
}
}
]
}
wp_orphans est la sonde qui n'a pas d'équivalent dans les quatre requêtes
ci-dessus, et elle vaut le détour : elle ne compte rien, elle exige zéro. Un
article qui a perdu son auteur est un article que les deux tables pleines ne
signalent pas.
Le plan des fichiers :
{
"steps": [
{
"type": "fetch",
"name": "Récupérer wp-content",
"config": {
"source": "source_profile",
"source_id": "<identifiant de votre source fichiers>",
"max_age": "26h",
"output": "wp-content.tar.gz"
}
},
{
"type": "unpack",
"name": "Extraire l'archive",
"config": {
"format": "tar.gz",
"input": "wp-content.tar.gz",
"output": "."
}
}
],
"probes": [
{
"name": "fichiers_du_site",
"type": "filesystem-canary",
"timeout": 60,
"config": {
"RESTOREPROOF_CANARY_FILES": "[{\"path\":\"wp-content/index.php\"},{\"path\":\"wp-content/themes\"},{\"path\":\"wp-content/plugins\"},{\"path\":\"wp-content/uploads\"}]",
"RESTOREPROOF_CANARY_MIN_FILES": "50",
"RESTOREPROOF_CANARY_MIN_TOTAL_SIZE": "102400"
}
}
]
}
Un site réel dépasse largement cinquante fichiers et cent kilooctets : relevez les deux planchers après votre premier essai, sinon la sonde passe au vert sur une archive qui a perdu la moitié de vos médias.
Et ensuite¶
- Applications auto-hébergées pour les autres applications qui se sauvegardent en deux morceaux.
- Bases de données pour les variantes de format MySQL et pour MariaDB.
- Choisir sa recette pour les sections communes à toutes les recettes.