# Sauvegarder Vaultwarden dans le stockage objet avec restic

[restic](https://github.com/restic/restic) est un programme de sauvegarde qui chiffre et signe chaque octet sur la machine sauvegardée, avant que quoi que ce soit atteigne le stockage. Son [guide Amazon S3](https://restic.readthedocs.io/en/stable/080_examples.html) est une référence connue. Comme le stockage objet ZCP utilise l’API S3, la même configuration fonctionne ici avec un point de terminaison différent.

Ce tutoriel réalise toute la configuration de bout en bout. Terraform ou OpenTofu provisionne une VM de gestionnaire de mots de passe Vaultwarden, un réseau, une IP publique et un compartiment de stockage objet. Un script Bash sauvegarde ensuite la base de données SQLite dans ce compartiment selon une minuterie nocturne.

À la fin, vous disposez de :

- Vaultwarden exécuté sur une VM accessible en HTTPS
- Un compartiment de stockage objet contenant un dépôt restic
- Un script de sauvegarde sur une minuterie systemd, avec rétention et contrôles d’intégrité
- La preuve que les données déposées dans le compartiment sont chiffrées, ainsi qu’une restauration qui les remet dans un coffre fonctionnel

Prévoyez environ 45 minutes.

Le chiffrement côté serveur n’est pas encore disponible dans le stockage objet ZCP. `zcp object-storage bucket encryption enable` refuse de s’exécuter, car la passerelle de stockage ne dispose pas de serveur principal de chiffrement et l’activation de SSE-S3 par défaut fait échouer chaque envoi ultérieur dans ce compartiment avec `InvalidArgument`.

restic ne l’attend pas. Il chiffre côté client, donc le compartiment ne reçoit que du texte chiffré. Le chiffrement côté serveur, lorsqu’il sera disponible, protège les données écrites par des clients qui ne chiffrent pas. Il n’apporte rien à un dépôt restic.

Les slugs de ce tutoriel, notamment la région `yul-1`, le projet `default-9`, le plan `ca2sm` et le modèle `zmi-vaultwarden-1360-ubuntu2404-100-1`, sont des exemples issus d’un compte. Les vôtres seront différents. Chaque étape présente la commande `zcp` qui affiche la bonne valeur pour votre compte. Utilisez-les au lieu de recopier les exemples.

## Avant de commencer

- Un compte ZSoftly Public Cloud. [Créez-en un](https://docs.zcp.zsoftly.ca/fr/public-cloud/getting-started/account-signup) si vous n’en avez pas.
- [Terraform](https://developer.hashicorp.com/terraform/install) 1.0 ou version ultérieure, ou [OpenTofu](https://opentofu.org/docs/intro/install/) 1.6 ou version ultérieure.
- Le [CLI `zcp`](https://docs.zcp.zsoftly.ca/fr/public-cloud/cli/installation), pour rechercher les slugs et inspecter le compartiment.
- Une paire de clés SSH. Générez-en une avec `ssh-keygen -t ed25519` si nécessaire.

## Étape 1 : Exporter votre jeton d’API

Dans la [console](https://cloud.zcp.zsoftly.ca/), allez à Compte → Clés d’API, créez un jeton et exportez-le. Le fournisseur et la CLI lisent la même variable :

```bash
export ZCP_BEARER_TOKEN="<your-token>"
```

Le jeton reste hors de votre configuration et de votre fichier d’état.

## Étape 2 : Trouver les slugs de votre compte

Le calcul et le stockage objet se trouvent dans des régions distinctes. Les machines virtuelles s’exécutent dans `yul-1` (Montréal) ou `yow-1` (Ottawa), et les stockages objet se trouvent dans `os-yul` ou `os-yow`. Choisissez la paire dans la même ville pour réduire la latence et les frais de transfert, ou l’autre ville pour conserver la sauvegarde hors du domaine de défaillance de la VM qu’elle protège.

```bash
zcp region list                                      # regions, including the os- ones
zcp project list                                     # your project slug
zcp plan vm --region yul-1                           # compute plans
zcp plan network --region yul-1                      # network plans
zcp plan ip --region yul-1                           # public IP plans
zcp storage-category list --region yul-1             # VM disk categories
zcp template list --region yul-1 | grep -i vaultwarden
zcp plan object-storage --region os-yul              # object storage sizes
zcp storage-category list --region os-yul            # object storage categories
```

L’image Vaultwarden est une [application Marketplace](https://docs.zcp.zsoftly.ca/fr/public-cloud/marketplace/vaultwarden). Elle fournit Vaultwarden dans Docker Compose sur Ubuntu 24.04, génère un certificat autosigné et un jeton administrateur au premier démarrage, puis sert le coffre web sur le port 8000.

## Étape 3 : Écrire la configuration

Créez un répertoire vide avec un fichier, `main.tf`. Le stockage objet utilise sa propre source de données de région, afin qu’aucune valeur ne soit codée en dur deux fois :

```hcl
terraform {
  required_providers {
    zcp = {
      source  = "zsoftly/zcp"
      version = "~> 0.2"
    }
  }
}


provider "zcp" {
  default_project = "default-9"
}


variable "admin_cidr" {
  description = "CIDR allowed to reach SSH and the web vault. Deliberately has no default."
  type        = string
}


data "zcp_region" "compute" {
  slug = "yul-1"
}


data "zcp_region" "objects" {
  slug = "os-yul"
}


resource "zcp_ssh_key" "vault" {
  name       = "vault-key"
  region     = data.zcp_region.compute.slug
  public_key = file("~/.ssh/id_ed25519.pub")
}


resource "zcp_network" "vault" {
  name           = "vault-network"
  cloud_provider = data.zcp_region.compute.cloud_provider
  region         = data.zcp_region.compute.slug
  network_plan   = "pnet-yul"
  billing_cycle  = "hourly"
}


resource "zcp_instance" "vault" {
  name             = "vault-01"
  cloud_provider   = data.zcp_region.compute.cloud_provider
  region           = data.zcp_region.compute.slug
  template         = "zmi-vaultwarden-1360-ubuntu2404-100-1"
  plan             = "ca2sm"
  billing_cycle    = "hourly"
  network          = zcp_network.vault.id
  storage_category = "premium-ssd"
  ssh_key          = zcp_ssh_key.vault.name
  assign_public_ip = false
}


resource "zcp_ip_address" "vault" {
  plan          = "ipv4-yul"
  billing_cycle = "hourly"
  network       = zcp_network.vault.id
}


resource "zcp_egress_rule" "https_out" {
  network    = zcp_network.vault.id
  protocol   = "tcp"
  cidr       = "0.0.0.0/0"
  start_port = "443"
  end_port   = "443"
}


resource "zcp_egress_rule" "http_out" {
  network    = zcp_network.vault.id
  protocol   = "tcp"
  cidr       = "0.0.0.0/0"
  start_port = "80"
  end_port   = "80"
}


resource "zcp_egress_rule" "dns_out" {
  network    = zcp_network.vault.id
  protocol   = "udp"
  cidr       = "0.0.0.0/0"
  start_port = "53"
  end_port   = "53"
}


resource "zcp_firewall_rule" "ssh" {
  ip_address = zcp_ip_address.vault.id
  protocol   = "tcp"
  cidr_list  = var.admin_cidr
  start_port = "22"
  end_port   = "22"
}


resource "zcp_firewall_rule" "vault_web" {
  ip_address = zcp_ip_address.vault.id
  protocol   = "tcp"
  cidr_list  = var.admin_cidr
  start_port = "8000"
  end_port   = "8000"
}


resource "zcp_port_forward" "ssh" {
  ip_address         = zcp_ip_address.vault.id
  protocol           = "tcp"
  public_start_port  = "22"
  public_end_port    = "22"
  private_start_port = "22"
  private_end_port   = "22"
  virtual_machine    = zcp_instance.vault.id
}


resource "zcp_port_forward" "vault_web" {
  ip_address         = zcp_ip_address.vault.id
  protocol           = "tcp"
  public_start_port  = "8000"
  public_end_port    = "8000"
  private_start_port = "8000"
  private_end_port   = "8000"
  virtual_machine    = zcp_instance.vault.id
}


resource "zcp_object_storage" "backups" {
  name             = "vault-backups"
  cloud_provider   = data.zcp_region.objects.cloud_provider
  region           = data.zcp_region.objects.slug
  billing_cycle    = "hourly"
  storage_category = "ssd-storage"
  plan             = "o210g"
}


resource "zcp_object_storage_bucket" "restic" {
  object_storage = zcp_object_storage.backups.id
  name           = "vaultwarden-restic"
}


output "vault_ip" {
  value = zcp_ip_address.vault.ip_address
}


output "object_storage_slug" {
  value = zcp_object_storage.backups.id
}


# The platform assigns the bucket a suffixed identifier when it is created, and that
# value is both the bucket slug and the S3 bucket name. The `name` argument above is
# only the name requested, so never use it in an endpoint URL.
output "bucket_name" {
  value = zcp_object_storage_bucket.restic.id
}


output "s3_access_key" {
  value     = zcp_object_storage.backups.api_key
  sensitive = true
}


output "s3_secret_key" {
  value     = zcp_object_storage.backups.api_secret
  sensitive = true
}
```

`admin_cidr` n’a pas de valeur par défaut. L’application s’arrête donc et demande une valeur au lieu d’exposer discrètement quoi que ce soit. Définissez-la à l’adresse depuis laquelle vous administrez, par exemple `tofu apply -var admin_cidr=203.0.113.10/32`, ou placez-la dans un `terraform.tfvars`.

Ne la définissez pas à `0.0.0.0/0`. Une règle aussi large expose un démon SSH et un coffre de mots de passe à tout Internet. Le coffre vaut plus que la VM. Trouvez votre adresse avec `curl -s ifconfig.me`. Si elle change, mettez à jour la variable et appliquez de nouveau au lieu d’élargir la règle.

Plusieurs choix de ce fichier ne sont pas évidents. Une erreur peut vous laisser avec une VM inaccessible ou impossible à sauvegarder.

Les règles de sortie sont obligatoires ici. Un réseau déclaré comme ressource distincte commence sans règle de sortie et refuse tout trafic sortant. Sans ces trois règles, la VM ne peut atteindre ni le dépôt de paquets, ni GitHub, ni le point de terminaison du stockage objet. Chaque commande des étapes suivantes expire. Les règles ci-dessus autorisent le trafic HTTPS, HTTP et DNS vers toute destination. Restreignez les CIDR de destination lorsque vous connaissez les hôtes nécessaires.

Cela concerne la gestion directe de votre réseau. Un réseau créé automatiquement par la VM, via `network_plan` ou l’option `--network-plan` de la CLI, est provisionné avec un accès sortant fonctionnel. Prendre la responsabilité du réseau pour un `destroy` propre implique aussi de prendre la responsabilité de ses règles de sortie.

Le trafic entrant passe par des redirections de port, pas par un NAT statique. La première IP publique allouée à un nouveau réseau devient son adresse source-NAT et la plateforme refuse d’y placer un NAT statique. Un `zcp_ip_association` sur cette IP indique une réussite sans rien changer, donc le trafic n’arrive jamais. Utilisez plutôt `zcp_port_forward`. Chaque redirection nécessite une règle de pare-feu sur la même IP pour laisser entrer le trafic.

`zcp_port_forward` exige `public_end_port` et `private_end_port`, même pour un seul port. La documentation du registre indique de les omettre pour une règle à port unique. L’API refuse cette omission avec `API error 500: Private end port is required`. Définissez le port final égal au port initial.

`assign_public_ip = false` sur l’instance empêche la plateforme d’allouer une deuxième adresse. La ressource `zcp_ip_address` fournit l’unique adresse utilisée par le réseau.

`zcp_ssh_key` accepte un argument `region`. La documentation du registre pour cette ressource l’omet. L’API en a besoin pour déterminer le fournisseur cloud, donc son absence fait échouer la validation.

Donnez son propre compartiment au dépôt restic. Un dépôt possède exclusivement les chemins dans lesquels il écrit, donc ne pointez pas une deuxième charge de travail vers le même compartiment.

## Étape 4 : Appliquer

#### OpenTofu

```bash
tofu init
tofu plan
tofu apply
```

#### Terraform

```bash
terraform init
terraform plan
terraform apply
```

L’application attend que la VM indique Running. La VM est l’élément lent, avec environ deux minutes et demie, et toute la pile est prête en trois à quatre minutes. Lisez les sorties :

```bash
tofu output vault_ip
tofu output object_storage_slug
tofu output bucket_name
tofu output -raw s3_access_key
tofu output -raw s3_secret_key
```

`object_storage_slug` est le slug du stockage, demandé comme premier argument par chaque commande `zcp object-storage`. `bucket_name` provient de l’`id` de la ressource de compartiment, que la plateforme définit à la valeur suffixée qu’elle a attribuée. Cette valeur suffixée est la chaîne à laquelle répond le point de terminaison S3. Confirmez-la dans la plateforme avant de vous y fier, car l’argument `name` de la ressource contient toujours le nom sans suffixe que vous avez saisi :

```bash
zcp object-storage bucket list <object_storage_slug> --region os-yul --project default-9
```

Terraform marque les deux sorties de clé comme sensibles, donc l’option `-raw` est nécessaire pour les afficher. Il les écrit tout de même en clair dans l’état. Tout fournisseur qui renvoie un identifiant se comporte de la même façon. Conservez le fichier d’état à un endroit où vous seriez prêt à conserver l’identifiant lui-même.

La plateforme crée les règles de pare-feu et de redirection de port de façon asynchrone. Elles mettent environ trente secondes à atteindre l’état Active après le retour de l’application, et SSH refuse la connexion avant cela. Si la première connexion expire, attendez puis réessayez avant de déboguer.

## Étape 5 : Ouvrir le coffre et y ajouter un élément

Le premier démarrage prend moins d’une minute. Connectez-vous et attendez la fin :

```bash
ssh ubuntu@<vault_ip>
journalctl -u vaultwarden-first-boot.service -f
```

Lisez le jeton administrateur généré :

```bash
sudo cat /root/.credentials/vaultwarden.txt
```

Ouvrez `https://<vault_ip>:8000` dans un navigateur. Le certificat autosigné déclenche un avertissement, acceptez donc l’exception. L’auto-inscription est désactivée par défaut. Invitez-vous depuis le panneau d’administration à `https://<vault_ip>:8000/admin`, sous Users, connectez-vous puis enregistrez un mot de passe. Vous supprimerez et restaurerez cet élément plus tard. Il doit donc exister avant la première sauvegarde.

Les données du coffre se trouvent dans `/data/vaultwarden/data` : `db.sqlite3`, les répertoires des pièces jointes et des envois, `config.json` et la paire de clés RSA qui signe les jetons clients. Le fichier Compose et le fichier d’environnement se trouvent un niveau plus haut dans `/data/vaultwarden`.

## Étape 6 : Installer restic sur la VM

Ubuntu fournit restic dans ses paquets, mais son archive accuse plusieurs versions de retard. Installez le binaire actuel et vérifiez-le avec les sommes de contrôle publiées :

```bash
sudo apt-get update
sudo apt-get install -y bzip2 rsync sqlite3


RESTIC_VERSION=0.19.1
cd /tmp
curl -fsSLO "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"
curl -fsSLO "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/SHA256SUMS"
sha256sum --ignore-missing -c SHA256SUMS


bunzip2 "restic_${RESTIC_VERSION}_linux_amd64.bz2"
sudo install -m 755 "restic_${RESTIC_VERSION}_linux_amd64" /usr/local/bin/restic
restic version
```

`sha256sum -c` doit afficher `OK` pour l’archive. Si ce n’est pas le cas, arrêtez-vous et téléchargez-la de nouveau.

Pour une vérification plus stricte, restic publie aussi `SHA256SUMS.asc`. Le [guide d’installation](https://restic.readthedocs.io/en/stable/020_installation.html) indique la clé de signature et les étapes de vérification.

## Étape 7 : Créer le dépôt

restic lit sa configuration dans l’environnement. Placez-la dans un fichier réservé à root plutôt que dans un profil shell afin que la minuterie systemd et votre shell interactif partagent une seule source de vérité.

Générez d’abord un mot de passe de dépôt. Toute personne qui obtient une copie du compartiment en a encore besoin pour lire votre coffre :

```bash
sudo install -d -m 700 /etc/restic
openssl rand -base64 32 | sudo tee /etc/restic/repo-password > /dev/null
sudo chmod 600 /etc/restic/repo-password
sudo cat /etc/restic/repo-password
```

Copiez ce mot de passe hors de cette VM avant de continuer et conservez-en une deuxième copie à un autre endroit. Il n’existe aucun moyen de récupération. Un dépôt restic dont le mot de passe est perdu est indiscernable de données aléatoires, ce qui est le but de cette conception. Le conserver uniquement dans le coffre que vous sauvegardez vous bloque précisément au moment où vous avez besoin de la sauvegarde.

Écrivez maintenant le fichier d’environnement en remplaçant le point de terminaison par celui de votre région, puis le nom du compartiment et les deux clés par les sorties Terraform :

```bash
sudo install -d -m 700 /var/cache/restic
sudo tee /etc/restic/vaultwarden.env > /dev/null <<'EOF'
RESTIC_REPOSITORY=s3:https://objects.yul.zcp.zsoftly.ca/<your-bucket-name>
RESTIC_PASSWORD_FILE=/etc/restic/repo-password
RESTIC_CACHE_DIR=/var/cache/restic
AWS_ACCESS_KEY_ID=<access-key>
AWS_SECRET_ACCESS_KEY=<secret-key>
EOF
sudo chmod 600 /etc/restic/vaultwarden.env
```

`RESTIC_CACHE_DIR` semble mineur, mais son absence casse la minuterie. systemd exécute les services sans `$HOME`, et restic refuse de démarrer lorsqu’il ne peut localiser un répertoire de cache. Sans cette variable, la minuterie nocturne de l’étape 9 échoue avec `unable to locate cache directory: neither $XDG_CACHE_HOME nor $HOME are defined`. La placer dans le fichier d’environnement partagé corrige à la fois la minuterie et votre shell interactif.

Les points de terminaison sont `https://objects.yul.zcp.zsoftly.ca` pour `os-yul` et `https://objects.yow.zcp.zsoftly.ca` pour `os-yow`. Le chemin après l’hôte est le nom du compartiment, qui correspond à la forme `s3:https://server/bucket` utilisée par restic pour tout serveur compatible S3.

Ne devinez pas le nom du compartiment. La plateforme ajoute un suffixe numérique au nom demandé, donc un compartiment déclaré comme `vaultwarden-restic` est créé sous un nom tel que `vaultwarden-restic-001024`. Cette valeur suffixée est le vrai nom du compartiment S3. Recherchez-le :

```bash
zcp object-storage bucket list <object_storage_slug> --region os-yul --project default-9
```

```plaintext
SLUG                       NAME                       OBJECTS  SIZE (GB)  STATUS
vaultwarden-restic-001024  vaultwarden-restic-001024  0                   Inactive
```

Utilisez la colonne NAME dans l’URL du dépôt. Les commandes CLI de niveau S3 utilisées plus loin dans ce tutoriel acceptent le même nom, même si leur aide décrit l’argument comme un slug. Seule `bucket delete` fonctionne avec le slug. Un compartiment dont l’état est `Inactive` et qui contient zéro objet est normal avant toute écriture.

Le fichier est réservé à root. Chargez-le donc dans un shell root plutôt que dans le vôtre. Chaque commande exécutée sur la VM d’ici à l’étape 11 utilise ce shell root et ne porte donc pas `sudo`. Les commandes `zcp` de l’étape 10 sont l’exception et s’exécutent sur votre poste de travail :

```bash
sudo -i
set -a; . /etc/restic/vaultwarden.env; set +a
restic init
```

```plaintext
created restic repository db62f25025 at s3:https://objects.yul.zcp.zsoftly.ca/vaultwarden-restic-001024


Please note that knowledge of your password is required to access
the repository. Losing your password means that your data is
irrecoverably lost.
```

Le stockage objet ZCP exige l’adressage de style chemin, mais restic le détecte lui-même et aucune option supplémentaire n’est nécessaire. Si une commande ne trouve pas un compartiment existant, ajoutez `-o s3.bucket-lookup=path` pour le forcer.

## Étape 8 : Écrire le script de sauvegarde

Vaultwarden conserve ses données dans SQLite. Copier un fichier SQLite actif avec `cp` peut capturer une transaction incomplète. La commande `.backup` du client `sqlite3` crée un instantané cohérent pendant que le conteneur continue de répondre. Aucune interruption n’est nécessaire.

Toujours dans le shell root, écrivez le script dans `/root/vaultwarden-backup.sh` :

```bash
#!/usr/bin/env bash
# Back up Vaultwarden to ZCP object storage with restic.
set -euo pipefail


VW_DIR=/data/vaultwarden
DATA_DIR="${VW_DIR}/data"
STAGE_ROOT=/var/backups/vaultwarden
STAGE_DIR="${STAGE_ROOT}/stage"
ENV_FILE=/etc/restic/vaultwarden.env


set -a
# shellcheck disable=SC1090
. "${ENV_FILE}"
set +a


install -d -m 700 "${STAGE_ROOT}"
install -d -m 700 "${STAGE_DIR}"
trap 'rm -rf "${STAGE_DIR:?}"' EXIT


# Copy the file-based data, skipping the live database and anything regenerable.
rsync -a --delete \
    --exclude 'db.sqlite3*' \
    --exclude 'icon_cache/' \
    --exclude 'tmp/' \
    --exclude 'ssl/' \
    "${DATA_DIR}/" "${STAGE_DIR}/data/"


# Snapshot the running database without stopping the container.
sqlite3 "${DATA_DIR}/db.sqlite3" ".backup '${STAGE_DIR}/data/db.sqlite3'"


# Refuse to upload a copy that is already damaged.
integrity=$(sqlite3 "${STAGE_DIR}/data/db.sqlite3" 'PRAGMA integrity_check;')
if [ "${integrity}" != "ok" ]; then
    echo "[ERROR] integrity check on the staged database returned: ${integrity}" >&2
    exit 1
fi


# The env file carries the admin token hash, so a restore needs it too.
install -m 600 "${VW_DIR}/vaultwarden.env" "${STAGE_DIR}/vaultwarden.env"


restic backup --tag vaultwarden "${STAGE_DIR}"


restic forget --tag vaultwarden \
    --keep-daily 7 --keep-weekly 4 --keep-monthly 6 \
    --prune


restic check


echo "[OK] vaultwarden backup finished"
```

Installez-le et exécutez-le une première fois manuellement :

```bash
install -m 700 /root/vaultwarden-backup.sh /usr/local/sbin/vaultwarden-backup.sh
/usr/local/sbin/vaultwarden-backup.sh
```

La première exécution téléverse tout. Les suivantes n’envoient que les blocs modifiés. Un coffre qui ajoute quelques mots de passe par jour ne consomme donc que quelques kilooctets par nuit. Un nouveau coffre produit un premier instantané très petit :

```plaintext
Files:           3 new,     0 changed,     0 unmodified
Dirs:            5 new,     0 changed,     0 unmodified
Added to the repository: 276.673 KiB (9.721 KiB stored)


processed 3 files, 274.046 KiB in 0:00
snapshot f5eb9b2f saved
```

Vérifiez ce qui a été envoyé :

```bash
restic snapshots
```

```plaintext
ID        Time                 Host       Tags         Paths                           Size
f5eb9b2f  2026-09-13 21:39:33  vwtest-01  vaultwarden  /var/backups/vaultwarden/stage  274.046 KiB
```

La ligne de rétention conserve 7 instantanés quotidiens, 4 hebdomadaires et 6 mensuels, puis supprime ce qui ne relève d’aucune de ces trois catégories. `restic check` vérifie que tous les paquets et index référencés existent. Sur un petit dépôt, cela prend quelques secondes. Lorsque le dépôt dépasse quelques dizaines de gigaoctets, placez cette vérification dans un minuteur hebdomadaire séparé et ajoutez-y `--read-data-subset=1/7`, qui lit et vérifie un septième des données de paquets à chaque exécution.

## Étape 9 : L’exécuter chaque nuit

Créez l’unité de service dans `/etc/systemd/system/vaultwarden-backup.service` :

```ini
[Unit]
Description=Back up Vaultwarden to ZCP object storage with restic
After=network-online.target docker.service
Wants=network-online.target


[Service]
Type=oneshot
ExecStart=/usr/local/sbin/vaultwarden-backup.sh
Nice=10
IOSchedulingClass=idle
```

Puis le minuteur dans `/etc/systemd/system/vaultwarden-backup.timer` :

```ini
[Unit]
Description=Daily Vaultwarden restic backup


[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=20m
Persistent=true


[Install]
WantedBy=timers.target
```

Activez-le :

```bash
systemctl daemon-reload
systemctl enable --now vaultwarden-backup.timer
systemctl list-timers vaultwarden-backup.timer
```

Confirmez son exécution sous systemd sans attendre le lendemain :

```bash
systemctl start vaultwarden-backup.service
systemctl show vaultwarden-backup.service -p Result -p ExecMainStatus
```

```plaintext
Result=success
ExecMainStatus=0
```

`OnCalendar` suit l’horloge de la VM, en UTC sauf modification de votre part. `Persistent=true` exécute au prochain démarrage une sauvegarde manquée. Une VM éteinte pendant la nuit rattrape donc son retard. Consultez la dernière exécution avec `journalctl -u vaultwarden-backup.service -n 50`.

Un `Result=exit-code` avec `unable to locate cache directory` dans le journal signifie que `RESTIC_CACHE_DIR` manque dans `/etc/restic/vaultwarden.env`. Le script fonctionne manuellement car le shell de connexion root définit `$HOME`. Il échoue sous le minuteur car systemd ne le définit pas.

## Étape 10 : Confirmer que le compartiment contient du texte chiffré

Cette étape répond à la question laissée ouverte par l’absence de chiffrement côté serveur. Chaque dépôt restic contient à sa racine un petit objet nommé `config`. Sur la VM, restic le déchiffre :

```bash
restic cat config
```

```json
{
  "version": 2,
  "id": "db62f2502541f42cc6af484cc6b9fc04b24498e3dc6430441a4156bcea6553de",
  "chunker_polynomial": "395c3db449e5db"
}
```

Récupérez maintenant le même objet directement du compartiment. Utilisez les identifiants de gestion de votre poste de travail plutôt que restic :

```bash
zcp object-storage object list <object_storage_slug> <your-bucket-name> \
  --region os-yul --project default-9


zcp object-storage object download <object_storage_slug> <your-bucket-name> config \
  --dest /tmp/restic-config --region os-yul --project default-9


file /tmp/restic-config
head -c 32 /tmp/restic-config | xxd
```

La liste présente la structure standard du dépôt :

```plaintext
KEY                                                                       SIZE
config                                                                     155
data/00/00d100ce161bbecdd894e17051b75e431d81d8178c3f25c665e27373e1b167ff  1945
data/a3/a31a963e4c25da5fc0a8446cb32840830b6ecf1aa70f26e5145863d6ed13d91c  8081
index/d17710e043e7ba09fa77ab93681d82dc4ca1bfe7a7ad915f0e45c0046987e07e     638
keys/78fd2a6413aa925c0f0fe801933cc68f18568ae82cb7dcede38121e288f25bb4      446
snapshots/f5eb9b2f32fd48703d1f1b4487d33195099250bdeffe625a94149653c66a46bf 379
```

Le même objet `config`, extrait directement du compartiment, est illisible :

```plaintext
/tmp/restic-config: data
00000000: 6498 2898 76cb 76fd 3a6a e81f 1dd5 b9db  d.(.v.v.:j......
00000010: eac5 6875 a1b5 b406 59c3 7b4c b264 c344  ..hu....Y.{L.d.D
```

`file` indique `data` et le vidage hexadécimal est du bruit. Il en va de même pour les paquets sous `data/`, qui contiennent le coffre. Rien dans le compartiment n’est lisible sans le mot de passe du dépôt. Ce mot de passe ne quitte jamais la VM ni votre copie hors ligne. restic chiffre avec AES-256 en mode compteur et authentifie avec Poly1305-AES. Un objet modifié échoue donc à la vérification MAC au lieu de se déchiffrer en données plausibles.

Activez la gestion des versions d’objets du compartiment. Si la VM est compromise, ses identifiants peuvent supprimer des objets. La gestion des versions conserve les versions précédentes récupérables :

```bash
zcp object-storage bucket versioning enable <object_storage_slug> <your-bucket-name> \
  --region os-yul --project default-9
```

## Étape 11 : Restaurer

Une sauvegarde que vous n’avez jamais restaurée reste une hypothèse. Vérifiez-la maintenant, quand la perte des données n’a pas de conséquence.

Supprimez du coffre web l’élément de mot de passe enregistré à l’étape 5. Restaurez ensuite l’instantané :

```bash
restic snapshots --tag vaultwarden
restic restore latest --target /tmp/vault-restore
```

```plaintext
restoring snapshot f5eb9b2f of [/var/backups/vaultwarden/stage] to /tmp/vault-restore
Summary: Restored 8 files/dirs (274.046 KiB) in 0:00
```

restore écrit l’arborescence dans `/tmp/vault-restore/var/backups/vaultwarden/stage`. Remettez la base de données avec le conteneur arrêté afin qu’aucune écriture ne survienne pendant l’opération :

```bash
cd /data/vaultwarden
docker compose stop
rsync -a --delete \
    --exclude 'ssl/' \
    --exclude 'icon_cache/' \
    --exclude 'tmp/' \
    /tmp/vault-restore/var/backups/vaultwarden/stage/data/ ./data/
chown -R root:root ./data
docker compose start
```

`--delete` en fait une restauration ponctuelle plutôt qu’une fusion. Les pièces jointes, envois et `config.json` reviennent donc à leur état au moment de l’instantané, et tout élément créé depuis est supprimé. Copier seulement la base de données les désynchroniserait.

Les trois exclusions reprennent celles du script de sauvegarde et rendent ici `--delete` sûr. rsync ne supprime pas un chemin exclu sauf avec `--delete-excluded`, donc le répertoire actif `ssl/` survit. C’est essentiel : `ROCKET_TLS` dans `vaultwarden.env` pointe sur `/data/ssl/cert.pem`, le certificat est généré sur la VM et n’est jamais préparé, et sa suppression empêche le conteneur de démarrer. Vaultwarden régénère `icon_cache/` et `tmp/`.

`vaultwarden.env` figure aussi dans l’instantané, mais laissez-le intact lors d’un retour en arrière sur la même VM. Une restauration de base ne le modifie pas et l’écraser supprimerait toute modification ultérieure de `DOMAIN` ou du jeton administrateur. Restaurez-le seulement lors d’une reconstruction sur une autre VM, comme ci-dessous.

Rechargez le coffre web et connectez-vous. L’élément supprimé est revenu. Pour vérifier sans navigateur, lisez directement la base avant et après :

```bash
sqlite3 /data/vaultwarden/data/db.sqlite3 'select email from users;'
```

Pour reconstruire plutôt sur une nouvelle VM, déployez la même image Marketplace et installez restic. Créez le même `/etc/restic/vaultwarden.env` et le même mot de passe de dépôt, puis restaurez depuis le dépôt existant. Copiez aussi `rsa_key.pem` et `config.json`. La clé RSA signe les jetons clients et son remplacement déconnecte tous les clients.

```bash
restic restore latest --target /tmp/vault-restore
cp /tmp/vault-restore/var/backups/vaultwarden/stage/vaultwarden.env \
  /data/vaultwarden/vaultwarden.env
```

## Nettoyer

Détruisez toute la pile après les essais :

#### OpenTofu

```bash
tofu destroy
```

#### Terraform

```bash
terraform destroy
```

La destruction ne se termine pas en une passe. Attendez-vous aux deux échecs suivants.

Videz d’abord le compartiment. Un compartiment qui contient un dépôt restic refuse d’être supprimé, et la destruction s’arrête avec `API error 403` sur `zcp_object_storage_bucket`. Videz-le puis relancez :

```bash
zcp object-storage bucket empty <object_storage_slug> <your-bucket-name> \
  --region os-yul --project default-9
```

```plaintext
Removed 9 object/version entries from bucket "vaultwarden-restic-001024".
```

L’IP publique est supprimée avec son réseau, pas seule. Comme l’adresse est l’IP source-NAT du réseau, sa libération directe échoue avec `API error 403: Source Nat IP cannot be deleted from the associated network`. Laissez la destruction du réseau s’en charger :

```bash
tofu state rm zcp_ip_address.vault
tofu destroy
```

Supprimer le réseau libère son adresse. Confirmez qu’il ne reste rien de facturable :

```bash
zcp instance list --region yul-1 --project default-9
zcp network list --region yul-1 --project default-9
zcp ip list --region yul-1 --project default-9
zcp object-storage list --region os-yul --project default-9
```

## Dépannage

Tout sur la VM expire, y compris `apt-get update`. Le réseau ne possède aucune règle de sortie. La VM ne peut donc joindre ni l’archive de paquets, ni GitHub, ni le point de terminaison du stockage d’objets. Vérifiez avec `zcp egress list --network <network-slug> --region yul-1`. Une liste vide est le problème. Appliquez les trois ressources `zcp_egress_rule` de l’étape 3. Une VM sur un réseau créé automatiquement ne rencontre pas ce cas, d’où l’absence de sortie dans les tutoriels CLI.

SSH expire juste après l’application. Les règles de pare-feu et de redirection s’activent de façon asynchrone et prennent environ trente secondes. Confirmez qu’elles sont toutes deux `Active` :

```bash
zcp firewall list --ip <ip-slug> --region yul-1 --project default-9
zcp portforward list --ip <ip-slug> --region yul-1 --project default-9
```

Une liste de pare-feu vide juste après la création d’une règle signifie généralement qu’elle n’est pas encore active. Cela ne signifie pas forcément qu’elle a échoué.

SSH expire toujours et les règles sont Active. Vérifiez les redirections de ports. Le NAT statique sur l’IP source-NAT du réseau ne fait silencieusement rien. Une configuration qui utilise `zcp_ip_association` au lieu de `zcp_port_forward` laisse la VM inaccessible tout en indiquant un succès.

`API error 500: Private end port is required`. Il manque `public_end_port` ou `private_end_port` à un `zcp_port_forward`. Définissez-les à la même valeur que les ports de début.

Le minuteur nocturne échoue mais le script fonctionne manuellement. `RESTIC_CACHE_DIR` manque dans `/etc/restic/vaultwarden.env`. systemd exécute les services sans `$HOME`.

`Fatal: unable to open config file` ou une erreur de compartiment introuvable. L’URL du dépôt désigne un compartiment inexistant, souvent parce que le suffixe numérique de la plateforme a été omis. Listez les noms réels avec `zcp object-storage bucket list <object_storage_slug> --region os-yul`.

`wrong password or no key found`. Le mot de passe du dépôt ne correspond pas. Vérifiez que `RESTIC_PASSWORD_FILE` pointe vers le fichier généré et que ce fichier n’a pas été modifié depuis.

Les téléversements échouent avec `InvalidArgument`. Le chiffrement par défaut est activé sur le compartiment. Désactivez-le avec `zcp object-storage bucket encryption disable <object_storage_slug> <your-bucket-name>` et vérifiez le réglage actuel avec `encryption status`.

## Étapes suivantes

- [Clés d’accès au stockage d’objets](https://docs.zcp.zsoftly.ca/fr/public-cloud/storage/object-storage/access-keys) et [Utilisation de l’API S3](https://docs.zcp.zsoftly.ca/fr/public-cloud/storage/object-storage/s3-usage/)
- [Gérer ZCP avec Terraform ou OpenTofu](https://docs.zcp.zsoftly.ca/tutorials/manage-infrastructure-terraform/) pour le cycle de vie du fournisseur de façon isolée
- [Vaultwarden sur le Marketplace](https://docs.zcp.zsoftly.ca/fr/public-cloud/marketplace/vaultwarden) pour la configuration de l’image et les notes de renforcement pour la production
- [Sauvegardes et instantanés d’instances](https://docs.zcp.zsoftly.ca/fr/public-cloud/backups-snapshots/backups) pour la récupération d’une VM entière en complément de cette approche au niveau des fichiers
- [Documentation restic](https://restic.readthedocs.io/en/stable/) pour les politiques, les dépôts distants et le montage d’un dépôt comme système de fichiers
