Cloud-init en pratique : setup complet d'une VM Scaleway avec Terraform
Guide pas à pas pour provisionner une VM sur Scaleway avec Terraform et cloud-init : création d'utilisateur, SSH, Docker, sshguard, montage de disque persistant, locale, et écriture de fichiers. Chaque directive est disséquée et sourcée.
Dans l’article précédent, on a vu la théorie : ce qu’est cloud-init, ses 5 étapes de boot, les formats de user-data, l’intégration Terraform, et les spécificités de Scaleway. Maintenant, on passe à la pratique.
L’objectif : créer une VM sur Scaleway avec Terraform, entièrement configurée au premier boot par cloud-init. Voici ce que notre configuration va faire :
- Créer un utilisateur
deployavec sudo sans mot de passe - Installer les mises à jour système
- Ajouter des clés SSH autorisées
- Installer des utilitaires classiques (vim, git, curl, cron, jq)
- Configurer la timezone
Europe/Paris - Monter un disque persistant Block Storage
- Installer et configurer sshguard avec une whitelist
- Écrire des fichiers de configuration et scripts
- Installer Docker et donner accès au user
deploy - Se connecter à un registry privé (ghcr.io) et déployer des conteneurs
- Mettre en place Traefik (reverse proxy) avec le plugin de cache Souin
On va d’abord voir la configuration Terraform, puis le template cloud-init complet, et enfin disséquer chaque section pour comprendre exactement ce qui se passe, dans quel ordre, et pourquoi.
1. Vue d’ensemble de l’architecture
┌──────────────────────────────────────────────────────────────┐
│ Terraform │
│ │
│ ┌──────────────────┐ ┌─────────────────────────────────┐ │
│ │ scaleway_iam_ │ │ scaleway_instance_ │ │
│ │ ssh_key │ │ security_group + rules │ │
│ └──────────────────┘ └─────────────────────────────────┘ │
│ │
│ ┌───────────────────┐ ┌─────────────────────────────────┐ │
│ │ scaleway_instance_│ │ scaleway_block_volume │ │
│ │ ip (IP publique) │ │ (disque persistant 50 GB) │ │
│ └───────────────────┘ └─────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ cloudinit_config "main" │ │
│ │ part 1: cloud-config.yaml (YAML déclaratif) │ │
│ │ part 2: post-install.sh (script impératif) │ │
│ │ → MIME multipart (texte brut, pas de gzip/base64) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ scaleway_instance_server │ │
│ │ type: DEV1-S | image: ubuntu_jammy │ │
│ │ user_data = cloudinit_config.main.rendered │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Premier boot ──→ cloud-init s'exécute :
Init : disk_setup, mounts, write_files, users_groups, ssh
Config : apt_configure, timezone, locale
Final : package_update_upgrade_install,
scripts_user (post-install.sh → sshguard, docker,
ghcr.io login, traefik + souin up)
Terraform crée l’infrastructure (réseau, stockage, instance), cloud-init configure l’OS. Ces deux responsabilités sont distinctes et complémentaires.
2. La configuration Terraform
2.1 Provider et versions
# versions.tf
terraform {
required_providers {
scaleway = {
source = "scaleway/scaleway"
version = ">= 2.41.0"
}
cloudinit = {
source = "hashicorp/cloudinit"
version = ">= 2.3"
}
}
required_version = ">= 1.0"
}
provider "scaleway" {
region = "fr-par"
zone = "fr-par-1"
}
On déclare deux providers : scaleway pour l’infrastructure, et cloudinit pour assembler notre cloud-config YAML et notre script shell en un seul document MIME multipart. On verra pourquoi dans la section 2.6.
Les credentials Scaleway sont passés via les variables d’environnement SCW_ACCESS_KEY, SCW_SECRET_KEY, et SCW_DEFAULT_PROJECT_ID. Ne jamais les mettre en dur dans les fichiers .tf. Source : Scaleway Terraform provider
2.2 Variables
# variables.tf
variable "ssh_public_key" {
description = "Clé publique SSH pour l'accès à l'instance"
type = string
}
variable "ssh_public_key_deploy" {
description = "Clé publique SSH pour le user deploy"
type = string
}
variable "instance_type" {
description = "Type commercial de l'instance Scaleway"
type = string
default = "DEV1-S"
}
variable "whitelisted_ips" {
description = "IPs autorisées dans sshguard (CIDR)"
type = list(string)
default = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
}
variable "allowed_ssh_cidrs" {
description = "CIDRs autorisés pour SSH dans le security group"
type = list(string)
default = ["0.0.0.0/0"]
}
variable "ghcr_username" {
description = "Nom d'utilisateur GitHub pour ghcr.io"
type = string
}
variable "ghcr_pat" {
description = "Personal Access Token GitHub (scope: read:packages)"
type = string
sensitive = true
}
variable "acme_email" {
description = "Email pour Let's Encrypt"
type = string
}
variable "traefik_domain" {
description = "Domaine principal pour Traefik"
type = string
}
Le sensitive = true sur ghcr_pat empêche Terraform d’afficher la valeur dans les logs et le plan. Mais attention : la valeur reste en clair dans le state Terraform et dans le user-data de l’instance (voir section sécurité plus bas).
2.3 Clé SSH
# ssh.tf
resource "scaleway_iam_ssh_key" "deploy" {
name = "deploy-key"
public_key = var.ssh_public_key
}
scaleway_iam_ssh_key est la ressource courante (elle remplace scaleway_account_ssh_key, dépréciée). La clé est enregistrée au niveau IAM du projet Scaleway. Source : Terraform Registry — scaleway_iam_ssh_key
2.4 Réseau et Security Group
# network.tf
resource "scaleway_instance_ip" "public" {}
resource "scaleway_instance_security_group" "main" {
name = "vm-sg"
inbound_default_policy = "drop"
outbound_default_policy = "accept"
external_rules = true
}
resource "scaleway_instance_security_group_rules" "main" {
security_group_id = scaleway_instance_security_group.main.id
dynamic "inbound_rule" {
for_each = var.allowed_ssh_cidrs
content {
action = "accept"
port = 22
protocol = "TCP"
ip_range = inbound_rule.value
}
}
inbound_rule {
action = "accept"
port = 80
protocol = "TCP"
ip_range = "0.0.0.0/0"
}
inbound_rule {
action = "accept"
port = 443
protocol = "TCP"
ip_range = "0.0.0.0/0"
}
inbound_rule {
action = "accept"
protocol = "ICMP"
ip_range = "0.0.0.0/0"
}
outbound_rule {
action = "accept"
protocol = "ANY"
}
}
Quelques points importants :
inbound_default_policy = "drop": tout ce qui n’est pas explicitement autorisé est rejeté. C’est la posture de sécurité de base.external_rules = true: les règles sont gérées viascaleway_instance_security_group_rules, séparées de la ressource security group. Cela évite les dépendances circulaires et rend les règles plus lisibles.dynamic "inbound_rule": Terraform itère survar.allowed_ssh_cidrspour créer une règle par CIDR autorisé pour SSH.
Source : Terraform Registry — scaleway_instance_security_group
2.5 Stockage persistant
# storage.tf
resource "scaleway_block_volume" "data" {
iops = 5000
name = "vm-data"
size_in_gb = 50
}
On utilise scaleway_block_volume (et non scaleway_instance_volume) pour du stockage réseau persistant. La ressource scaleway_instance_volume est adaptée au stockage local (l_ssd) lié au cycle de vie de l’instance. Le block volume, lui, survit à la suppression de l’instance et peut être détaché/rattaché.
Le paramètre iops est obligatoire et détermine le tier de performance :
| Tier | IOPS | Usage type |
|---|---|---|
sbs_5k | 5 000 | Stockage général |
sbs_15k | 15 000 | Bases de données, I/O intensif |
Source : Terraform Registry — scaleway_block_volume
2.6 Assemblage MIME multipart avec cloudinit_config
Le problème central : on a besoin d’un cloud-config YAML (configuration déclarative) ET d’un script shell (commandes post-installation). Or, cloud-init n’accepte qu’un seul user-data. La solution : le MIME multipart, qui permet d’empaqueter plusieurs fichiers en un seul document.
Le provider Terraform cloudinit fait exactement ça :
# cloudinit.tf
data "cloudinit_config" "main" {
gzip = false # Scaleway n'accepte que du texte brut
base64_encode = false # Pas d'encodage non plus
# Partie 1 : cloud-config YAML (déclaratif)
part {
filename = "cloud-config.yaml"
content_type = "text/cloud-config"
content = templatefile("${path.module}/templates/cloud-init.yml.tftpl", {
username = "deploy"
ssh_public_key = var.ssh_public_key_deploy
whitelisted_ips = var.whitelisted_ips
acme_email = var.acme_email
traefik_domain = var.traefik_domain
})
}
# Partie 2 : script shell (post-installation)
part {
filename = "post-install.sh"
content_type = "text/x-shellscript"
content = templatefile("${path.module}/templates/post-install.sh.tftpl", {
ghcr_username = var.ghcr_username
ghcr_pat = var.ghcr_pat
})
}
}
Le résultat de data.cloudinit_config.main.rendered est un document MIME comme celui-ci :
Content-Type: multipart/mixed; boundary="MIMEBOUNDARY"
MIME-Version: 1.0
--MIMEBOUNDARY
Content-Transfer-Encoding: 7bit
Content-Type: text/cloud-config
Content-Disposition: attachment; filename="cloud-config.yaml"
#cloud-config
users:
- name: deploy
...
--MIMEBOUNDARY
Content-Transfer-Encoding: 7bit
Content-Type: text/x-shellscript
Content-Disposition: attachment; filename="post-install.sh"
#!/bin/bash
set -euo pipefail
...
--MIMEBOUNDARY--
Cloud-init reconnaît ce format automatiquement et traite chaque partie selon son Content-Type : le cloud-config YAML passe par les modules habituels (stages Init/Config/Final), et le script shell s’exécute au stage Final, après l’installation des paquets. C’est exactement ce qu’on veut.
Pourquoi
gzip = falseetbase64_encode = false? Scaleway n’accepte que du texte brut : « The Scaleway Instances API only supports plain text representations of user-data, meaning that any compressed format, like gzip, cannot be used. » Source : Scaleway docs
Source : Terraform cloudinit provider
2.7 L’instance
# instance.tf
resource "scaleway_instance_server" "main" {
name = "my-server"
type = var.instance_type
image = "ubuntu_jammy"
ip_id = scaleway_instance_ip.public.id
security_group_id = scaleway_instance_security_group.main.id
tags = ["managed-by-terraform"]
root_volume {
size_in_gb = 20
volume_type = "l_ssd"
delete_on_termination = true
}
additional_volume_ids = [scaleway_block_volume.data.id]
user_data = {
cloud-init = data.cloudinit_config.main.rendered
}
depends_on = [scaleway_iam_ssh_key.deploy]
}
Points clés :
data.cloudinit_config.main.rendered: le document MIME multipart assemblé par le providercloudinit. Il contient le cloud-config ET le script shell en un seul bloc de texte.user_dataavec la clé"cloud-init": c’est le mécanisme Scaleway pour passer du cloud-init. La clécloud-initest réservée. Source : Terraform Registry — scaleway_instance_serveradditional_volume_ids: attache le block volume à l’instance. Le volume apparaîtra comme/dev/sdbdans l’instance. Attention : modifieradditional_volume_idsprovoque un stop/start du serveur.depends_on: la clé SSH IAM doit exister avant la création de l’instance, sinon Scaleway ne l’injectera pas dans les métadonnées.
2.8 Outputs
# outputs.tf
output "server_public_ip" {
value = scaleway_instance_ip.public.address
}
output "ssh_command" {
value = "ssh deploy@${scaleway_instance_ip.public.address}"
}
3. Les templates complets
On a deux fichiers : le cloud-config YAML (configuration déclarative) et le script shell post-installation. Ils sont assemblés en MIME multipart par cloudinit_config (section 2.6).
3.1 Le cloud-config — templates/cloud-init.yml.tftpl
#cloud-config
# ── 1. Utilisateur ─────────────────────────────────────
users:
- name: ${username}
gecos: "Deploy User"
groups: [sudo, docker]
shell: /bin/bash
lock_passwd: true
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ${ssh_public_key}
# ── 2. Groupe Docker ───────────────────────────────────
groups:
- docker
# ── 3. Mises à jour système ────────────────────────────
package_update: true
package_upgrade: true
# ── 4. Timezone ────────────────────────────────────────
timezone: Europe/Paris
# ── 5. Locale ──────────────────────────────────────────
locale: fr_FR.UTF-8
# ── 6. Dépôt APT Docker ───────────────────────────────
apt:
sources:
docker.list:
source: "deb [arch=amd64 signed-by=$KEY_FILE] https://download.docker.com/linux/ubuntu $RELEASE stable"
keyid: "9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
# ── 7. Paquets ─────────────────────────────────────────
packages:
- vim
- git
- curl
- cron
- jq
- nftables
- sshguard
- ca-certificates
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
# ── 8. Partitionnement du disque persistant ────────────
disk_setup:
/dev/sdb:
table_type: gpt
layout: true
overwrite: false
fs_setup:
- label: data
filesystem: ext4
device: /dev/sdb
partition: auto
overwrite: false
mounts:
- [/dev/sdb1, /mnt/data, ext4, "defaults,nofail,noatime", "0", "2"]
# ── 9. Fichiers de configuration ──────────────────────
write_files:
# sshguard — whitelist
- path: /etc/sshguard/whitelist
content: |
# Whitelist sshguard — IPs de confiance
127.0.0.0/8
::1/128
%{ for ip in whitelisted_ips ~}
${ip}
%{ endfor ~}
owner: root:root
permissions: "0644"
# sshguard — configuration
- path: /etc/sshguard/sshguard.conf
content: |
BACKEND="/usr/libexec/sshguard/sshg-fw-nft-sets"
LOGREADER="LANG=C /usr/bin/journalctl -afb -p info -n1 -t sshd -o cat"
THRESHOLD=30
BLOCK_TIME=120
DETECTION_TIME=1800
WHITELIST_FILE=/etc/sshguard/whitelist
BLACKLIST_FILE=120:/var/lib/sshguard/blacklist.db
owner: root:root
permissions: "0644"
# Script de healthcheck
- path: /opt/scripts/healthcheck.sh
content: |
#!/bin/bash
set -euo pipefail
echo "=== Healthcheck $(date) ==="
echo "Uptime: $(uptime -p)"
echo "Disk usage:"
df -h /mnt/data
echo "Docker status:"
systemctl is-active docker || echo "Docker not running"
echo "sshguard status:"
systemctl is-active sshguard || echo "sshguard not running"
owner: root:root
permissions: "0755"
# Docker daemon config
- path: /etc/docker/daemon.json
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
owner: root:root
permissions: "0644"
# ── 10. Traefik — configuration statique ─────────────
- path: /mnt/data/traefik/traefik.yml
content: |
api:
dashboard: true
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: proxy
file:
directory: "/dynamic"
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: "${acme_email}"
storage: "/acme.json"
httpChallenge:
entryPoint: web
experimental:
plugins:
souin:
moduleName: github.com/darkweak/souin
version: v1.7.8
owner: root:root
permissions: "0644"
# Traefik — config dynamique : middleware cache Souin
- path: /mnt/data/traefik/dynamic/souin-cache.yml
content: |
http:
middlewares:
http-cache:
plugin:
souin:
default_cache:
ttl: 300s
stale: 86400s
allowed_http_verbs:
- GET
- HEAD
log_level: INFO
owner: root:root
permissions: "0644"
# Traefik — docker-compose.yml
- path: /mnt/data/traefik/docker-compose.yml
content: |
services:
traefik:
image: traefik:v3.6
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/traefik.yml:ro
- ./dynamic:/dynamic:ro
- ./acme.json:/acme.json
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.${traefik_domain}`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.dashboard.service=api@internal"
networks:
proxy:
name: proxy
driver: bridge
owner: root:root
permissions: "0644"
Pas de runcmd dans le cloud-config. On verra pourquoi dans la section 4.10.
3.2 Le script post-installation — templates/post-install.sh.tftpl
#!/bin/bash
set -euo pipefail
# Ce script s'exécute au stage Final de cloud-init,
# APRÈS l'installation des paquets.
# ── Répertoire pour la blacklist sshguard ──────────────
mkdir -p /var/lib/sshguard
# ── sshguard : activer et redémarrer avec notre config ─
systemctl enable sshguard.service
systemctl restart sshguard.service
# ── Docker : s'assurer qu'il est actif ─────────────────
# --no-block évite un deadlock connu avec cloud-init
# (https://github.com/moby/moby/issues/41767)
systemctl enable docker.service
systemctl start --no-block docker.service
# Attendre que Docker soit prêt (le --no-block rend la main
# immédiatement, mais on a besoin de Docker pour la suite)
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break
sleep 2
done
# ── Login au registry privé ghcr.io ───────────────────
echo "${ghcr_pat}" | docker login ghcr.io \
-u "${ghcr_username}" --password-stdin
# ── Préparer acme.json (Let's Encrypt) ────────────────
# Traefik exige que ce fichier ait les permissions 600
touch /mnt/data/traefik/acme.json
chmod 600 /mnt/data/traefik/acme.json
# ── Lancer la stack Traefik ────────────────────────────
cd /mnt/data/traefik
docker compose up -d
Pourquoi un script séparé ? La réponse tient en un mot : l’ordre d’exécution.
Un script shell (#!/bin/bash) en user-data s’exécute au stage Final de cloud-init — « relatively late in the boot process, during cloud-init’s final stage » (Source : user-data script format). Et dans le stage Final, il s’exécute après le module cc_package_update_upgrade_install. Ça signifie que quand notre script tourne, sshguard, docker-ce et tous les autres paquets sont déjà installés.
À l’inverse, runcmd (dans le cloud-config) s’exécute au stage Config, avant l’installation des paquets. Un systemctl restart sshguard ou un docker login dans runcmd échouerait car ces binaires n’existent pas encore.
Le MIME multipart est le seul moyen de combiner cloud-config et script shell dans un seul user-data. Le provider cloudinit l’assemble automatiquement (section 2.6).
Pourquoi .tftpl et pas .sh ? Le script contient des variables Terraform (${ghcr_pat}, ${ghcr_username}) qui doivent être interpolées par templatefile(). L’extension .tftpl signale cette intention.
4. Dissection du template : chaque section expliquée
4.1 Création de l’utilisateur — users
users:
- name: ${username}
gecos: "Deploy User"
groups: [sudo, docker]
shell: /bin/bash
lock_passwd: true
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ${ssh_public_key}
Module cloud-init : cc_users_groups
Fréquence : per-instance (premier boot uniquement)
Stage : Init (Network) — exécuté très tôt, avant l’installation des paquets
| Directive | Rôle | Détails |
|---|---|---|
name | Nom du compte Unix | ${username} est interpolé par Terraform (templatefile) |
gecos | Champ GECOS (nom complet) | Purement informatif, visible dans /etc/passwd |
groups | Groupes secondaires | sudo pour les privilèges admin, docker pour accéder au socket Docker |
shell | Shell de login | /bin/bash — le défaut serait /bin/sh |
lock_passwd: true | Verrouille le mot de passe | Interdit l’authentification par mot de passe. Seul SSH par clé fonctionne |
sudo | Règle sudoers | Écrit littéralement dans /etc/sudoers.d/90-cloud-init-users |
ssh_authorized_keys | Clés publiques SSH | Écrites dans ~deploy/.ssh/authorized_keys |
Le user default : on ne l’inclut pas (- default absent). Par défaut, sur une image Ubuntu Scaleway, le user ubuntu existe. En n’incluant pas - default, on ne le crée pas explicitement, mais on ne le supprime pas non plus. Si on veut uniquement le user deploy, on peut ajouter - default puis le user, ou ajouter disable_root: true et ssh_pwauth: false pour plus de sécurité.
Pourquoi lock_passwd: true ? En cloud, l’authentification par clé SSH est la norme. Un mot de passe est une surface d’attaque supplémentaire. En verrouillant le mot de passe, même si quelqu’un obtient un accès console, il ne pourra pas se connecter sans la clé privée correspondante.
« Setting
lock_passwd: truewill lock the user’s password, disabling password-based logins. » Source : cloud-init — Configure users and groups
4.2 Création du groupe Docker — groups
groups:
- docker
Module : cc_users_groups (le même que les users)
Stage : Init
On crée le groupe docker explicitement. Pourquoi ? Parce que cc_users_groups s’exécute au stage Init, avant l’installation des paquets (stage Final). Si on ne crée pas le groupe à l’avance, la directive groups: [sudo, docker] sur le user échouera silencieusement pour docker (le groupe n’existe pas encore).
L’ordre d’exécution garantit : création du groupe → création du user (avec son appartenance au groupe) → installation de Docker (qui trouve le groupe existant).
4.3 Mises à jour système — package_update / package_upgrade
package_update: true
package_upgrade: true
Module : cc_package_update_upgrade_install
Fréquence : per-instance
Stage : Final
package_update: true→ exécuteapt-get update(rafraîchit l’index des paquets)package_upgrade: true→ exécuteapt-get dist-upgrade(met à jour tous les paquets installés)
Ces directives s’exécutent au stage Final, après que le module cc_apt_configure ait ajouté nos sources tierces (le dépôt Docker). Ainsi, quand apt-get update tourne, il indexe aussi les paquets Docker.
Attention :
package_upgrade: truepeut prendre plusieurs minutes sur une image fraîche. C’est le prix à payer pour une VM sécurisée dès le premier boot.
4.4 Timezone — timezone
timezone: Europe/Paris
Module : cc_timezone
Fréquence : per-instance
Stage : Config
La valeur est un nom de timezone de la base tz (/usr/share/zoneinfo/). Cloud-init fait deux choses :
- Écrit
Europe/Parisdans/etc/timezone - Crée un lien symbolique
/etc/localtime → /usr/share/zoneinfo/Europe/Paris
Cloud-init ne passe pas par timedatectl set-timezone. Il manipule les fichiers directement. Le commentaire dans le code source de cloud-init l’explique : « timedatectl complains if invoked during startup ». Source : cc_timezone.py
On peut vérifier après le boot :
timedatectl
# → Time zone: Europe/Paris (CET, +0100)
date
# → Wed Feb 26 14:30:00 CET 2026
4.5 Locale — locale
locale: fr_FR.UTF-8
Module : cc_locale
Fréquence : per-instance
Stage : Config
Sur Debian/Ubuntu, cloud-init :
- Exécute
locale-gen fr_FR.UTF-8pour générer la locale - Exécute
update-locale LANG=fr_FR.UTF-8qui écrit dans/etc/default/locale
Là encore, pas de localectl — manipulation directe des fichiers. Source : cc_locale.py
4.6 Dépôt APT Docker — apt
apt:
sources:
docker.list:
source: "deb [arch=amd64 signed-by=$KEY_FILE] https://download.docker.com/linux/ubuntu $RELEASE stable"
keyid: "9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
Module : cc_apt_configure
Fréquence : per-instance
Stage : Config
C’est la manière déclarative d’ajouter un dépôt APT tiers. Pas de curl | gpg dans un runcmd. Cloud-init gère tout :
keyid: cloud-init contacte un keyserver (par défautkeyserver.ubuntu.com) et télécharge la clé GPG correspondant à l’empreinte9DC858229FC7DD38854AE2D88D81803C0EBFCD88(la clé officielle de Docker Release CE)- La clé est stockée dans un fichier dont le chemin est exposé via la variable
$KEY_FILE source: la ligne APT est écrite dans/etc/apt/sources.list.d/docker.list$RELEASE: variable cloud-init qui résout vers le codename Ubuntu (jammy,noble, etc.)
Source : cloud-init — Configure APT
Pourquoi pas le runcmd avec curl ? L’approche déclarative via apt est :
- Idempotente : cloud-init ne ré-ajoute pas le dépôt s’il existe
- Ordonnée :
cc_apt_configuretourne dans le bon stage, avant l’installation des paquets - Lisible : on voit d’un coup d’œil les sources tierces dans le cloud-config
| Approche | Idempotence | Ordre garanti | GPG auto |
|---|---|---|---|
apt.sources (déclaratif) | Oui | Oui | Oui (keyid) |
runcmd avec curl/gpg | Non | Manuelle | Non |
Note :
arch=amd64est hardcodé ici. Pour des instances ARM (ex.AMP2-C4), il faudraitarch=arm64. On pourrait rendre cela dynamique avec une variable Terraform supplémentaire.
4.7 Installation des paquets — packages
packages:
- vim
- git
- curl
- cron
- jq
- nftables
- sshguard
- ca-certificates
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
Module : cc_package_update_upgrade_install
Stage : Final
L’ordre dans la liste n’a pas d’importance — apt-get install résout les dépendances automatiquement. Ce qui compte, c’est que le dépôt Docker soit déjà configuré (ce qui est garanti par l’ordre des stages : cc_apt_configure au stage Config → cc_package_update_upgrade_install au stage Final).
Regroupement logique des paquets :
| Paquet | Rôle |
|---|---|
vim, git, curl, jq | Utilitaires de base |
cron | Planification de tâches (souvent pré-installé, mais on s’assure) |
nftables | Backend firewall pour sshguard |
sshguard | Protection anti-brute-force SSH |
ca-certificates | Certificats racine pour HTTPS (nécessaire pour le dépôt Docker) |
docker-ce, docker-ce-cli | Moteur Docker et CLI |
containerd.io | Runtime de conteneurs (dépendance Docker) |
docker-buildx-plugin | Plugin de build multi-plateforme |
docker-compose-plugin | Plugin Docker Compose v2 (docker compose au lieu de docker-compose) |
Source : Install Docker Engine on Ubuntu
4.8 Partitionnement et montage du disque — disk_setup, fs_setup, mounts
disk_setup:
/dev/sdb:
table_type: gpt
layout: true
overwrite: false
fs_setup:
- label: data
filesystem: ext4
device: /dev/sdb
partition: auto
overwrite: false
mounts:
- [/dev/sdb1, /mnt/data, ext4, "defaults,nofail,noatime", "0", "2"]
C’est la partie la plus délicate. Elle gère trois étapes séquentielles, dans cet ordre exact :
Étape A — disk_setup (partitionnement)
Module : cc_disk_setup — Stage : Init (Network)
| Directive | Valeur | Signification |
|---|---|---|
/dev/sdb | Le device | Premier volume additionnel sur Scaleway (le root est /dev/sda) |
table_type: gpt | Type de table de partitions | GPT est le standard moderne. MBR est limité à 2 TB |
layout: true | Schéma de partitionnement | true = une seule partition utilisant tout le disque |
overwrite: false | Protection des données | Si des partitions existent déjà, ne rien faire. Critique pour les redéploiements |
Si on voulait un partitionnement plus fin :
# Exemple : 70% pour les données, 30% pour les logs
disk_setup:
/dev/sdb:
table_type: gpt
layout: [70, 30]
Étape B — fs_setup (système de fichiers)
| Directive | Valeur | Signification |
|---|---|---|
label: data | Label du FS | Utilisable ensuite via LABEL=data dans fstab |
filesystem: ext4 | Type de filesystem | ext4 est le choix standard. xfs est préférable pour du stockage très orienté séquentiel/gros fichiers |
device: /dev/sdb | Device source | Cloud-init trouvera la partition automatiquement grâce à partition: auto |
partition: auto | Sélection de partition | auto = la première partition sans filesystem existant. Alternatives : none (device brut), any, ou un numéro |
overwrite: false | Protection | Si un filesystem existe déjà, on le conserve |
Étape C — mounts (montage + fstab)
mounts:
- [/dev/sdb1, /mnt/data, ext4, "defaults,nofail,noatime", "0", "2"]
C’est un tableau de 6 éléments correspondant aux champs de /etc/fstab :
| Position | Champ fstab | Valeur | Rôle |
|---|---|---|---|
| 0 | fs_spec | /dev/sdb1 | Device ou identifiant (UUID, LABEL) |
| 1 | fs_file | /mnt/data | Point de montage |
| 2 | fs_vfstype | ext4 | Type de filesystem |
| 3 | fs_mntops | defaults,nofail,noatime | Options de montage |
| 4 | fs_freq | 0 | Dump (0 = pas de dump) |
| 5 | fs_passno | 2 | Ordre de fsck (1 = root, 2 = autres, 0 = pas de check) |
Les options de montage sont cruciales :
defaults: activerw,suid,dev,exec,auto,nouser,asyncnofail: indispensable — permet au système de booter même si le volume n’est pas disponible. Sansnofail, un volume détaché = une VM qui ne démarre plusnoatime: désactive la mise à jour du timestamp d’accès à chaque lecture. Réduit les écritures inutiles, améliore les performances sur du block storage
Cloud-init écrit automatiquement cette ligne dans /etc/fstab et exécute le mount. Le point de montage /mnt/data est créé automatiquement s’il n’existe pas (ou on le crée explicitement dans runcmd par sécurité).
Source : cloud-init — disk_setup, mounts
Nommage des devices sur Scaleway : les block volumes apparaissent comme des devices SCSI sous
/dev/sdX. Le root est/dev/sda, le premier volume additionnel/dev/sdb, etc. Pour une identification stable (multi-volumes), on peut utiliser/dev/disk/by-id/scsi-0SCW_sbs_volume-<uuid>. Source : Scaleway — Identifying devices
4.9 Fichiers de configuration — write_files
Module : cc_write_files
Fréquence : per-instance
Stage : Init (Network) pour les fichiers normaux ; Final pour les fichiers avec defer: true
La whitelist sshguard
- path: /etc/sshguard/whitelist
content: |
# Whitelist sshguard — IPs de confiance
127.0.0.0/8
::1/128
%{ for ip in whitelisted_ips ~}
${ip}
%{ endfor ~}
owner: root:root
permissions: "0644"
Ici, %{ for ... ~} est la syntaxe de boucle de templatefile() (Terraform), pas du Jinja cloud-init. Les ~ suppriment les whitespace en excès. La variable whitelisted_ips est la liste passée depuis Terraform.
Le résultat, après rendu par Terraform :
# Whitelist sshguard — IPs de confiance
127.0.0.0/8
::1/128
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
Le fichier whitelist de sshguard supporte : des IPs individuelles, des blocs CIDR, et des hostnames (résolus au démarrage de sshguard). Le # doit être le premier caractère de la ligne pour être un commentaire. Source : sshguard documentation
La configuration sshguard
- path: /etc/sshguard/sshguard.conf
content: |
BACKEND="/usr/libexec/sshguard/sshg-fw-nft-sets"
LOGREADER="LANG=C /usr/bin/journalctl -afb -p info -n1 -t sshd -o cat"
THRESHOLD=30
BLOCK_TIME=120
DETECTION_TIME=1800
WHITELIST_FILE=/etc/sshguard/whitelist
BLACKLIST_FILE=120:/var/lib/sshguard/blacklist.db
owner: root:root
permissions: "0644"
Décortiquons chaque directive :
| Directive | Valeur | Rôle |
|---|---|---|
BACKEND | sshg-fw-nft-sets | Utilise nftables avec des sets IP. C’est le backend recommandé sur Ubuntu 22.04+ |
LOGREADER | journalctl -afb -p info -n1 -t sshd -o cat | Lit les logs SSH depuis le journal systemd. -f = follow, -t sshd = filtre sur sshd, -o cat = sortie brute |
THRESHOLD | 30 | Score cumulé déclenchant le blocage (chaque tentative échouée = ~10 points) |
BLOCK_TIME | 120 | Durée initiale du blocage : 2 minutes. Double à chaque récidive |
DETECTION_TIME | 1800 | Fenêtre de 30 minutes pour accumuler le score. Après 30 min sans tentative, le score est remis à zéro |
WHITELIST_FILE | /etc/sshguard/whitelist | Chemin vers notre fichier de whitelist |
BLACKLIST_FILE | 120:/var/lib/sshguard/blacklist.db | Blacklist permanente : si le score dépasse 120, l’IP est bannie définitivement (persisté sur disque) |
SSHGuard vs fail2ban : sshguard est écrit en C (vs Python pour fail2ban), beaucoup plus léger en RAM/CPU, et fonctionne out of the box pour SSH sans configuration de regex. fail2ban est plus flexible (supporte n’importe quel service), mais cette flexibilité a un coût en complexité.
Le backend nftables : quand sshguard démarre, il crée automatiquement des tables sshguard dans les familles ip et ip6 de nftables. Les IPs bloquées sont ajoutées à des sets nftables. Aucune création manuelle de chaîne n’est nécessaire (contrairement au backend iptables qui nécessite un iptables -N sshguard préalable).
On peut vérifier les règles nftables après le boot :
nft list ruleset
Bug connu (Ubuntu 24.04) :
systemctl reload nftablessupprime les tables sshguard. Seul un restart de sshguard les recrée. Source : Bug #2108012
Source : sshguard setup, ArchWiki — sshguard
Le script de healthcheck
- path: /opt/scripts/healthcheck.sh
content: |
#!/bin/bash
set -euo pipefail
echo "=== Healthcheck $(date) ==="
echo "Uptime: $(uptime -p)"
echo "Disk usage:"
df -h /mnt/data
echo "Docker status:"
systemctl is-active docker || echo "Docker not running"
echo "sshguard status:"
systemctl is-active sshguard || echo "sshguard not running"
owner: root:root
permissions: "0755"
Un exemple d’écriture de script via write_files. Le fichier est écrit au stage Init avec les permissions 0755 (exécutable). On pourrait l’ajouter à un cron via runcmd :
runcmd:
- echo "0 * * * * root /opt/scripts/healthcheck.sh >> /var/log/healthcheck.log 2>&1" > /etc/cron.d/healthcheck
La configuration Docker daemon
- path: /etc/docker/daemon.json
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
owner: root:root
permissions: "0644"
Le module write_files crée automatiquement les répertoires parents — cloud-init appelle os.makedirs() avant d’écrire. Le répertoire /etc/docker/ est donc créé au stage Init, même si Docker n’est pas encore installé. Quand Docker s’installe au stage Final, il trouve daemon.json déjà en place et l’utilise au démarrage du service.
C’est l’approche la plus propre : le fichier de config est prêt avant l’installation, pas besoin de redémarrer Docker après.
Quand utiliser
defer: true? L’optiondeferreporte l’écriture au stage Final (viacc_write_files_deferred, après l’installation des paquets). C’est utile quand le owner du fichier est un utilisateur créé par un paquet (ex.owner: nginx:nginxnécessite que nginx soit installé). Dans notre cas,owner: root:rootexiste toujours, doncdefern’est pas nécessaire.
La configuration du daemon :
| Option | Valeur | Rôle |
|---|---|---|
log-driver | json-file | Driver de log par défaut. Les logs sont stockés dans /var/lib/docker/containers/<id>/<id>-json.log |
max-size | 10m | Rotation quand un fichier de log atteint 10 Mo |
max-file | 3 | Garde 3 fichiers de rotation maximum (= 30 Mo max par conteneur) |
storage-driver | overlay2 | Driver de stockage recommandé pour les noyaux 4.0+ |
Sans cette configuration de rotation, les logs Docker peuvent remplir le disque root. C’est un problème classique en production.
Source : Docker daemon configuration
4.10 Script post-installation — post-install.sh
#!/bin/bash
set -euo pipefail
mkdir -p /var/lib/sshguard
systemctl enable sshguard.service
systemctl restart sshguard.service
systemctl enable docker.service
systemctl start --no-block docker.service
Format : script shell (text/x-shellscript dans le MIME multipart)
Fréquence : une fois par instance
Stage : Final — après l’installation des paquets
Ce script est la deuxième partie du MIME multipart assemblé par cloudinit_config. Cloud-init le détecte par son shebang #!/bin/bash et l’exécute au stage Final, via le module cc_scripts_user.
| Commande | Rôle |
|---|---|
mkdir -p /var/lib/sshguard | Crée le répertoire pour la blacklist persistante de sshguard |
systemctl enable sshguard | Active sshguard au boot |
systemctl restart sshguard | Redémarre sshguard pour charger notre config custom (whitelist + blacklist) |
systemctl enable docker | Active Docker au boot (normalement fait par le paquet, mais on s’assure) |
systemctl start --no-block docker | Démarre Docker sans bloquer |
Pourquoi --no-block pour Docker ? Il existe un bug connu : systemctl start docker sans --no-block peut provoquer un deadlock quand il est exécuté depuis cloud-final.service. Docker a des dépendances systemd qui créent une attente circulaire. --no-block queue le démarrage et retourne immédiatement.
Pourquoi pas runcmd ? Le module runcmd s’exécute au stage Config, avant le stage Final. Les paquets ne sont pas encore installés à ce stade — systemctl restart sshguard échouerait silencieusement. Le script shell, lui, tourne au stage Final, après cc_package_update_upgrade_install. C’est la bonne solution.
runcmdreste utile pour des commandes qui ne dépendent pas de paquets installés parpackages: manipuler des fichiers, configurer le noyau, ajuster/etc/hosts, etc. Mais dès qu’on a besoin de binaires installés par cloud-init, il faut un script shell séparé.
4.11 Configuration Traefik + Souin — write_files
Les fichiers Traefik sont écrits sur le disque persistant (/mnt/data/traefik/). Ainsi, les certificats Let’s Encrypt (acme.json) et la configuration dynamique survivent à un redéploiement de l’instance.
La config statique Traefik — traefik.yml
- path: /mnt/data/traefik/traefik.yml
content: |
api:
dashboard: true
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: proxy
file:
directory: "/dynamic"
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: "${acme_email}"
storage: "/acme.json"
httpChallenge:
entryPoint: web
experimental:
plugins:
souin:
moduleName: github.com/darkweak/souin
version: v1.7.8
Décortiquons les blocs clés :
| Bloc | Rôle |
|---|---|
entryPoints.web | Écoute sur le port 80. Redirige automatiquement vers HTTPS |
entryPoints.websecure | Écoute sur le port 443 (TLS) |
providers.docker | Découvre les conteneurs via le socket Docker. exposedByDefault: false force l’opt-in via le label traefik.enable=true |
providers.file | Charge les fichiers YAML dans /dynamic/. watch: true recharge automatiquement quand un fichier change |
certificatesResolvers.letsencrypt | Challenge HTTP-01 sur le port 80. Les certificats sont stockés dans /acme.json |
experimental.plugins.souin | Charge le plugin Souin au démarrage via Yaegi (interpréteur Go de Traefik) |
Deux providers coexistent : Docker pour les conteneurs (routes déclarées via labels), et File pour la configuration manuelle (middleware partagé, routes vers des services externes). C’est la force de Traefik — pas besoin de choisir. Source : Traefik Docker provider
Le middleware cache Souin — dynamic/souin-cache.yml
- path: /mnt/data/traefik/dynamic/souin-cache.yml
content: |
http:
middlewares:
http-cache:
plugin:
souin:
default_cache:
ttl: 300s
stale: 86400s
allowed_http_verbs:
- GET
- HEAD
log_level: INFO
Souin est un cache HTTP conforme RFC-7234, écrit en Go. Il s’intègre dans Traefik comme un plugin (middleware), exécuté directement dans le processus Traefik. Pas de conteneur séparé.
| Option | Valeur | Rôle |
|---|---|---|
ttl | 300s | Durée de vie du cache : 5 minutes |
stale | 86400s | Sert du contenu stale pendant 24h si le backend ne répond pas |
allowed_http_verbs | GET, HEAD | Seuls GET et HEAD sont mis en cache (le défaut sensé) |
Pour appliquer ce middleware à un conteneur, on ajoute le label : traefik.http.routers.<name>.middlewares=http-cache.
Le fichier est dans le répertoire dynamic/ du file provider — Traefik le charge automatiquement et le recharge si on le modifie. On peut ajouter d’autres fichiers de config dynamique (security headers, rate limiting, etc.) dans ce même répertoire.
Source : Souin — Traefik middleware, Traefik plugins registry
Le docker-compose.yml Traefik
- path: /mnt/data/traefik/docker-compose.yml
content: |
services:
traefik:
image: traefik:v3.6
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/traefik.yml:ro
- ./dynamic:/dynamic:ro
- ./acme.json:/acme.json
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.${traefik_domain}`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.dashboard.service=api@internal"
networks:
proxy:
name: proxy
driver: bridge
Points importants :
/var/run/docker.sock:ro: monté en lecture seule — Traefik n’a besoin que de lire les événements Docker, pas d’écrire.security_opt: no-new-privilegesempêche l’escalade de privilèges../acme.json: doit avoir les permissions600— Traefik refuse de démarrer sinon. Le scriptpost-install.shs’en charge.- Le réseau
proxy: tous les conteneurs qui doivent être exposés par Traefik doivent rejoindre ce réseau. C’est le réseau de référence déclaré dansproviders.docker.network. traefik.${traefik_domain}: le dashboard est accessible via un sous-domaine. La variable est interpolée par Terraform.
Pour ajouter un conteneur privé ghcr.io avec Traefik, il suffit de créer un autre docker-compose.yml (ou d’ajouter un service) avec les bons labels :
services:
myapp:
image: ghcr.io/myorg/myapp:latest
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
- "traefik.http.routers.myapp.entrypoints=websecure"
- "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
- "traefik.http.routers.myapp.middlewares=http-cache"
- "traefik.http.services.myapp.loadbalancer.server.port=3000"
networks:
proxy:
external: true
Le docker login ghcr.io effectué dans post-install.sh rend les images privées accessibles. Le réseau proxy est déclaré external: true pour réutiliser celui créé par la stack Traefik.
4.12 Login ghcr.io et sécurité — post-install.sh
echo "${ghcr_pat}" | docker login ghcr.io \
-u "${ghcr_username}" --password-stdin
--password-stdin lit le token depuis stdin au lieu de le passer en argument (qui serait visible dans ps aux). Les credentials sont stockés dans /root/.docker/config.json après le login.
Le scope minimum du PAT : read:packages suffit pour puller des images privées. Ne pas donner write:packages ni delete:packages à moins d’en avoir besoin. Utiliser un PAT dédié avec une date d’expiration courte. Source : GitHub — Container registry authentication
Considérations de sécurité : le PAT se retrouve dans trois endroits :
| Emplacement | Risque | Mitigation |
|---|---|---|
| User-data de l’instance | Lisible par root via l’API métadonnées (169.254.42.42). Sur Scaleway, l’accès est restreint aux ports < 1024 (root uniquement) | Scope minimal (read:packages), PAT dédié, expiration courte |
| State Terraform | Même marqué sensitive, le PAT est en clair dans le state | Chiffrer le state (S3 + SSE, Terraform Cloud), restreindre l’accès |
/root/.docker/config.json | Lisible par root sur l’instance | Normal — c’est le fonctionnement standard de Docker. Restreindre l’accès root |
4.13 Alternative : récupérer le PAT depuis Scaleway Secret Manager
L’approche ci-dessus fonctionne, mais le PAT se retrouve en clair dans le user-data et dans le state Terraform. On peut faire mieux avec Scaleway Secret Manager : stocker le PAT dans un secret, donner à l’instance une clé API restreinte qui ne peut que lire ce secret, et le récupérer au runtime.
Le principe
┌─────────────────────┐ ┌──────────────────────────┐
│ Terraform │ │ Scaleway Secret Manager │
│ │ crée │ │
│ scaleway_secret ├─────────→│ secret: "ghcr-pat" │
│ scaleway_secret_ │ │ version 1: "ghp_xxx" │
│ version │ │ │
│ │ └──────────┬───────────────┘
│ scaleway_iam_ │ │
│ application │ │ curl + X-Auth-Token
│ scaleway_iam_policy │ │
│ scaleway_iam_ │ ┌──────────┴───────────────┐
│ api_key ──────────┼─────────→│ Instance (post-install) │
│ (injecté via │ cloud- │ 1. curl → récupère PAT │
│ cloud-init) │ init │ 2. docker login ghcr.io│
└─────────────────────┘ │ 3. efface la clé API │
└──────────────────────────┘
Au lieu d’injecter le PAT directement, on injecte une clé API Scaleway à scope réduit (SecretManagerReadOnly + SecretManagerSecretAccess). Le script récupère le PAT au runtime via l’API, l’utilise pour docker login, puis peut effacer les credentials. Le PAT n’apparaît jamais dans le user-data.
Infrastructure Terraform
# secrets.tf
# 1. Stocker le PAT dans Secret Manager
resource "scaleway_secret" "ghcr_pat" {
name = "ghcr-pat"
description = "Personal Access Token GitHub pour ghcr.io"
type = "opaque"
protected = true # empêche la suppression accidentelle
}
resource "scaleway_secret_version" "ghcr_pat_v1" {
secret_id = scaleway_secret.ghcr_pat.id
data = var.ghcr_pat
description = "Version initiale"
}
# 2. Créer une application IAM dédiée (service account)
resource "scaleway_iam_application" "secret_reader" {
name = "instance-secret-reader"
description = "Application pour lire les secrets depuis l'instance"
}
# 3. Attacher une policy avec le minimum de permissions
resource "scaleway_iam_policy" "secret_read" {
name = "secret-read-only"
application_id = scaleway_iam_application.secret_reader.id
rule {
project_ids = [var.project_id]
permission_set_names = [
"SecretManagerReadOnly", # lister/voir les métadonnées
"SecretManagerSecretAccess" # lire la valeur du secret
]
}
}
# 4. Générer une clé API pour cette application
resource "scaleway_iam_api_key" "secret_reader" {
application_id = scaleway_iam_application.secret_reader.id
description = "Clé API pour lire les secrets depuis l'instance"
default_project_id = var.project_id
}
Les deux permission sets sont nécessaires ensemble : SecretManagerReadOnly donne l’accès aux métadonnées (lister, identifier les secrets), et SecretManagerSecretAccess donne l’accès aux données sensibles (l’endpoint /access qui retourne la valeur). Source : Scaleway — Permission sets
Note : le PAT reste dans le state Terraform (via
scaleway_secret_version.data), mais il n’est plus dans le user-data de l’instance. C’est un vecteur d’exposition en moins.
Variables additionnelles
# variables.tf (à ajouter)
variable "project_id" {
description = "ID du projet Scaleway"
type = string
}
variable "scw_region" {
description = "Région Scaleway pour Secret Manager"
type = string
default = "fr-par"
}
Adaptation de cloudinit.tf
Avec cette approche, le script post-installation reçoit la clé API Scaleway et l’ID du secret au lieu du PAT :
# cloudinit.tf (part post-install.sh — variante Secret Manager)
part {
filename = "post-install.sh"
content_type = "text/x-shellscript"
content = templatefile("${path.module}/templates/post-install.sh.tftpl", {
ghcr_username = var.ghcr_username
scw_secret_key = scaleway_iam_api_key.secret_reader.secret_key
scw_region = var.scw_region
secret_id = scaleway_secret.ghcr_pat.id
})
}
Script post-install.sh.tftpl — variante Secret Manager
#!/bin/bash
set -euo pipefail
# --- sshguard ---
mkdir -p /var/lib/sshguard
systemctl enable sshguard.service
systemctl restart sshguard.service
# --- Docker ---
systemctl enable docker.service
systemctl start --no-block docker.service
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break
sleep 2
done
# --- Récupérer le PAT depuis Scaleway Secret Manager ---
GHCR_PAT=$(curl -sf \
-H "X-Auth-Token: ${scw_secret_key}" \
"https://api.scaleway.com/secret-manager/v1beta1/regions/${scw_region}/secrets/${secret_id}/versions/latest/access" \
| jq -r '.data' | base64 -d)
echo "$GHCR_PAT" | docker login ghcr.io \
-u "${ghcr_username}" --password-stdin
# Nettoyer la variable (le PAT reste dans /root/.docker/config.json)
unset GHCR_PAT
# --- Traefik ---
touch /mnt/data/traefik/acme.json
chmod 600 /mnt/data/traefik/acme.json
cd /mnt/data/traefik
docker compose up -d
L’appel à l’API Secret Manager retourne un JSON avec un champ data en base64 — c’est le format standard de l’API. On le décode avec base64 -d pour obtenir le PAT en clair, utilisé une seule fois pour docker login. Source : Scaleway Secret Manager API
Ce que ça change concrètement
| Approche directe | Approche Secret Manager | |
|---|---|---|
| PAT dans le user-data | Oui | Non |
| PAT dans le state Terraform | Oui (var.ghcr_pat) | Oui (scaleway_secret_version.data) |
| Credentials dans le user-data | Le PAT lui-même | Une clé API Scaleway (scope réduit) |
| Rotation du PAT | Redéployer l’instance | Créer une nouvelle version du secret |
| Révocation | Révoquer le PAT sur GitHub | Révoquer le PAT ou la clé API |
| Complexité | Simple | Plus de ressources Terraform, appel API |
L’avantage principal : la rotation du PAT ne nécessite pas de redéployer l’instance. On crée une nouvelle version du secret dans Secret Manager, et au prochain docker login (ou au prochain boot), le script récupère automatiquement la dernière version (/versions/latest/access).
Aller plus loin : supprimer la clé API après usage
Pour les plus paranoïaques, on pourrait même supprimer la clé API après le premier boot, via un second appel à l’API IAM. Mais ça empêcherait de re-puller les images après un docker compose pull ultérieur. À réserver aux setups où le PAT est injecté dans un credential store Docker plutôt que dans config.json.
Point important : Scaleway n’a pas d’équivalent aux Instance Profiles d’AWS (où l’instance reçoit automatiquement des credentials temporaires via le service de métadonnées). Il faut toujours provisionner explicitement une clé API sur l’instance. C’est la principale différence architecturale avec AWS/GCP. Source : Scaleway IAM documentation
5. Ordre d’exécution complet
Récapitulatif chronologique de ce qui se passe au premier boot :
STAGE INIT (cloud-init-network.service — bloque SSH)
│
├─ cc_disk_setup → Partitionne /dev/sdb en GPT
├─ cc_mounts → Formate en ext4, monte sur /mnt/data, écrit fstab
├─ cc_write_files → Écrit les fichiers de configuration :
│ ├─ /etc/sshguard/whitelist
│ ├─ /etc/sshguard/sshguard.conf
│ ├─ /opt/scripts/healthcheck.sh
│ ├─ /etc/docker/daemon.json (crée /etc/docker/)
│ ├─ /mnt/data/traefik/traefik.yml
│ ├─ /mnt/data/traefik/dynamic/souin-cache.yml
│ └─ /mnt/data/traefik/docker-compose.yml
├─ cc_users_groups → Crée le groupe docker, crée le user deploy
│ avec sudo NOPASSWD et clé SSH
└─ cc_ssh → Configure les clés SSH host
STAGE CONFIG (cloud-config.service)
│
├─ cc_apt_configure → Ajoute le dépôt Docker (GPG key + sources.list.d)
├─ cc_timezone → Configure Europe/Paris (/etc/timezone + /etc/localtime)
└─ cc_locale → Configure fr_FR.UTF-8 (locale-gen + update-locale)
STAGE FINAL (cloud-final.service)
│
├─ cc_package_update → apt-get update + apt-get dist-upgrade
│ _upgrade_install apt-get install vim git curl cron jq nftables
│ sshguard docker-ce docker-ce-cli containerd.io ...
│ (Docker trouve daemon.json déjà en place → l'utilise)
├─ cc_scripts_user → Exécute post-install.sh :
│ 1. mkdir /var/lib/sshguard + restart sshguard
│ 2. enable/start docker + attendre le daemon
│ 3. docker login ghcr.io (avec PAT GitHub)
│ 4. chmod 600 acme.json (Let's Encrypt)
│ 5. docker compose up -d (Traefik + Souin)
└─ cc_final_message → Log de fin cloud-init
C’est l’avantage du MIME multipart : le cloud-config YAML gère la configuration déclarative (stages Init et Config), et le script shell gère les commandes impératives qui dépendent des paquets (stage Final). Pas de piège d’ordre d’exécution.
Note : on n’utilise pas
runcmddans cette configuration. Si on en avait besoin pour des commandes indépendantes des paquets (modifier/etc/sysctl.conf, ajouter des entrées/etc/hosts, etc.), on pourrait l’ajouter au cloud-config — il s’exécuterait au stage Config, entre l’APT configure et l’installation des paquets.
6. Déploiement et vérification
6.1 Déployer
# Initialiser Terraform (télécharge les providers scaleway + cloudinit)
terraform init
# Créer un fichier terraform.tfvars (ne PAS le committer !)
cat > terraform.tfvars << 'EOF'
ssh_public_key = "ssh-ed25519 AAAA..."
ssh_public_key_deploy = "ssh-ed25519 AAAA..."
ghcr_username = "mon-user-github"
ghcr_pat = "ghp_xxxxxxxxxxxx"
acme_email = "admin@example.com"
traefik_domain = "example.com"
EOF
# Prévisualiser les changements
terraform plan
# Appliquer
terraform apply
Sécurité : le fichier
terraform.tfvarscontient le PAT GitHub en clair. Ajoutez-le à.gitignore. En production, préférez passer les secrets via des variables d’environnement (TF_VAR_ghcr_pat) ou un outil comme Vault.
6.2 Vérifier cloud-init
# Se connecter
ssh deploy@<IP>
# Vérifier que cloud-init a terminé
cloud-init status --long
# Si ça tourne encore, attendre
cloud-init status --wait
# Voir le détail des temps d'exécution par module
cloud-init analyze blame
6.3 Vérifier chaque composant
# User et groupes
id deploy
# → uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),27(sudo),998(docker)
# Timezone
timedatectl | grep "Time zone"
# → Time zone: Europe/Paris (CET, +0100)
# Locale
locale
# → LANG=fr_FR.UTF-8
# Disque monté
df -h /mnt/data
# → /dev/sdb1 49G ... /mnt/data
cat /etc/fstab | grep sdb
# → /dev/sdb1 /mnt/data ext4 defaults,nofail,noatime 0 2
# sshguard
systemctl status sshguard
nft list ruleset | grep sshguard
# Docker
docker version
docker compose version
docker run --rm hello-world
# Connexion ghcr.io
sudo cat /root/.docker/config.json | jq '.auths["ghcr.io"]'
# → { "auth": "..." } (base64 du username:pat)
# Traefik + Souin
docker compose -f /mnt/data/traefik/docker-compose.yml ps
# → traefik running 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
curl -s http://localhost:8080/api/overview | jq .
# → dashboard Traefik (si activé et accessible localement)
docker compose -f /mnt/data/traefik/docker-compose.yml logs traefik --tail 20
# → vérifier que le plugin Souin est chargé et que l'ACME fonctionne
# Fichiers écrits
cat /etc/sshguard/whitelist
cat /etc/docker/daemon.json
ls -la /opt/scripts/healthcheck.sh
ls -la /mnt/data/traefik/traefik.yml
ls -la /mnt/data/traefik/dynamic/souin-cache.yml
ls -la /mnt/data/traefik/acme.json
# → -rw------- (permissions 600 obligatoires pour Let's Encrypt)
6.4 En cas de problème
# Log principal cloud-init (verbeux, avec DEBUG)
less /var/log/cloud-init.log
# Stdout/stderr des scripts et commandes
less /var/log/cloud-init-output.log
# Valider le user-data tel que cloud-init l'a reçu
cloud-init schema --system
# Voir les métadonnées Scaleway
curl --local-port 1-1023 http://169.254.42.42/user_data/cloud-init
7. Points d’attention et pièges courants
Le piège runcmd vs stage Final
runcmd s’exécute au stage Config, avant l’installation des paquets (stage Final). Si votre runcmd fait référence à un binaire installé par packages, la commande échouera silencieusement.
La solution, utilisée dans cet article : le MIME multipart. On sépare le cloud-config YAML (déclaratif, traité aux stages Init/Config/Final) du script shell (impératif, exécuté au stage Final après les paquets). Le provider Terraform cloudinit assemble les deux en un seul user-data.
runcmd reste pertinent pour des commandes qui ne dépendent pas de paquets : manipuler des fichiers système, configurer le réseau, modifier /etc/hosts, etc.
Scaleway et cloudinit_config
Scaleway n’accepte que du texte brut pour le user-data — pas de gzip, pas de base64. Mais le MIME multipart en texte brut fonctionne parfaitement. Il suffit de configurer cloudinit_config avec gzip = false et base64_encode = false :
data "cloudinit_config" "main" {
gzip = false # obligatoire pour Scaleway
base64_encode = false # obligatoire pour Scaleway
# ...
}
« The Scaleway Instances API only supports plain text representations of user-data, meaning that any compressed format, like gzip, cannot be used. » Source : Scaleway docs
nofail dans les options de montage
Sans nofail, si le block volume est détaché (maintenance, erreur Terraform), l’instance ne bootera plus. C’est le piège classique avec les volumes additionnels. Toujours ajouter nofail.
Premier boot uniquement
Toutes les directives de notre template sont per-instance. Si on reboot la VM, cloud-init ne ré-exécutera pas le user-data (sauf bootcmd qu’on n’utilise pas ici). Pour forcer une ré-exécution complète :
sudo cloud-init clean --logs --reboot
Attention : cette commande peut régénérer les clés SSH host et écraser des fichiers modifiés manuellement. Ne jamais faire ça en production sans savoir ce qu’on fait.
Le device /dev/sdb et la stabilité
/dev/sdb est le premier volume additionnel au moment de la création. Ce nommage est déterministe à la création, mais pourrait théoriquement changer si des volumes sont ajoutés/retirés. Pour une identification stable :
# Alternative avec le UUID du volume Scaleway
disk_setup:
/dev/disk/by-id/scsi-0SCW_sbs_volume-<uuid>:
table_type: gpt
layout: true
overwrite: false
On peut récupérer l’UUID du volume depuis Terraform : scaleway_block_volume.data.id.
8. Variantes et extensions
Ajouter un cron job
runcmd:
- |
cat > /etc/cron.d/healthcheck << 'CRON'
0 * * * * root /opt/scripts/healthcheck.sh >> /var/log/healthcheck.log 2>&1
CRON
Configurer le MOTD
write_files:
- path: /etc/motd
content: |
╔═══════════════════════════════════════╗
║ Production Server — Handle with care ║
╚═══════════════════════════════════════╝
permissions: "0644"
Ajouter plusieurs clés SSH
Dans le template Terraform :
ssh_authorized_keys:
%{ for key in ssh_keys ~}
- ${key}
%{ endfor ~}
Avec la variable :
variable "ssh_keys" {
type = list(string)
}
Désactiver l’accès root SSH
disable_root: true
ssh_pwauth: false
disable_root: true empêche la connexion SSH en tant que root (même avec une clé). ssh_pwauth: false désactive l’authentification par mot de passe pour SSH globalement.
Sources principales
Cloud-init
- cloud-init documentation — documentation officielle
- Configure users and groups — module users
- Configure APT — module apt
- Configure disk setup — modules disk_setup et fs_setup
- Configure mounts — module mounts
- Writing out files — module write_files
- Run commands during boot — modules runcmd et bootcmd
- Set locale and timezone — modules locale et timezone
- Module reference — référence complète des modules
- Boot stages — ordre d’exécution
- cc_timezone.py — code source du module timezone
- cc_locale.py — code source du module locale
Scaleway
- Scaleway — How to use cloud-init — guide officiel
- Scaleway — Using cloud-init with API and CLI — limitations et usage
- Scaleway — How to mount block storage — montage de volumes
- Scaleway — Identifying devices — nommage des devices
Terraform
- Scaleway provider — documentation du provider
- scaleway_instance_server — ressource instance
- scaleway_block_volume — ressource block volume
- scaleway_instance_security_group — security groups
- scaleway_iam_ssh_key — clés SSH
- templatefile() — fonction de templating
Docker
- Install Docker Engine on Ubuntu — installation officielle
- Docker daemon configuration — daemon.json
- moby/moby #41767 — bug systemctl start deadlock avec cloud-init
SSHGuard
- sshguard documentation — documentation officielle
- sshguard.conf sample — exemple de configuration
- ArchWiki — sshguard — guide complet
- Bug #2108012 — nftables reload breaks sshguard — bug Ubuntu 24.04
Traefik et Souin
- Traefik documentation — documentation officielle
- Traefik Docker provider — découverte automatique des conteneurs
- Traefik File provider — configuration dynamique via fichiers
- Traefik Let’s Encrypt / ACME — certificats TLS automatiques
- Traefik plugin Souin — plugin de cache HTTP RFC-7234
- Souin documentation — documentation du cache HTTP
- Souin GitHub — code source et configuration
GitHub Container Registry
- GitHub — Working with the container registry — authentification et usage
- GitHub — Creating a PAT (classic) — création de tokens
- Docker login reference — authentification vers un registry
Scaleway Secret Manager et IAM
- Secret Manager documentation — documentation officielle
- Secret Manager API reference — endpoints et format de réponse
- Secret Manager concepts — secrets, versions, types, ephemeral policies
- scaleway_secret — ressource Terraform
- scaleway_secret_version — ressource Terraform
- IAM — Permission sets — référence des permissions
- IAM — Managing applications — service accounts
- IAM — Creating policies — rattacher des permissions
- scaleway_iam_application — ressource Terraform
- scaleway_iam_policy — ressource Terraform
- Tutorial : Secret Manager + Terraform — guide officiel