fr

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 deploy avec 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 via scaleway_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 sur var.allowed_ssh_cidrs pour 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 :

TierIOPSUsage type
sbs_5k5 000Stockage général
sbs_15k15 000Bases 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 = false et base64_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 provider cloudinit. Il contient le cloud-config ET le script shell en un seul bloc de texte.
  • user_data avec la clé "cloud-init" : c’est le mécanisme Scaleway pour passer du cloud-init. La clé cloud-init est réservée. Source : Terraform Registry — scaleway_instance_server
  • additional_volume_ids : attache le block volume à l’instance. Le volume apparaîtra comme /dev/sdb dans l’instance. Attention : modifier additional_volume_ids provoque 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

DirectiveRôleDétails
nameNom du compte Unix${username} est interpolé par Terraform (templatefile)
gecosChamp GECOS (nom complet)Purement informatif, visible dans /etc/passwd
groupsGroupes secondairessudo pour les privilèges admin, docker pour accéder au socket Docker
shellShell de login/bin/bash — le défaut serait /bin/sh
lock_passwd: trueVerrouille le mot de passeInterdit l’authentification par mot de passe. Seul SSH par clé fonctionne
sudoRègle sudoersÉcrit littéralement dans /etc/sudoers.d/90-cloud-init-users
ssh_authorized_keysClé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: true will 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écute apt-get update (rafraîchit l’index des paquets)
  • package_upgrade: true → exécute apt-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: true peut 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 :

  1. Écrit Europe/Paris dans /etc/timezone
  2. 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 :

  1. Exécute locale-gen fr_FR.UTF-8 pour générer la locale
  2. Exécute update-locale LANG=fr_FR.UTF-8 qui é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 :

  1. keyid : cloud-init contacte un keyserver (par défaut keyserver.ubuntu.com) et télécharge la clé GPG correspondant à l’empreinte 9DC858229FC7DD38854AE2D88D81803C0EBFCD88 (la clé officielle de Docker Release CE)
  2. La clé est stockée dans un fichier dont le chemin est exposé via la variable $KEY_FILE
  3. source : la ligne APT est écrite dans /etc/apt/sources.list.d/docker.list
  4. $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_configure tourne dans le bon stage, avant l’installation des paquets
  • Lisible : on voit d’un coup d’œil les sources tierces dans le cloud-config
ApprocheIdempotenceOrdre garantiGPG auto
apt.sources (déclaratif)OuiOuiOui (keyid)
runcmd avec curl/gpgNonManuelleNon

Note : arch=amd64 est hardcodé ici. Pour des instances ARM (ex. AMP2-C4), il faudrait arch=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 :

PaquetRôle
vim, git, curl, jqUtilitaires de base
cronPlanification de tâches (souvent pré-installé, mais on s’assure)
nftablesBackend firewall pour sshguard
sshguardProtection anti-brute-force SSH
ca-certificatesCertificats racine pour HTTPS (nécessaire pour le dépôt Docker)
docker-ce, docker-ce-cliMoteur Docker et CLI
containerd.ioRuntime de conteneurs (dépendance Docker)
docker-buildx-pluginPlugin de build multi-plateforme
docker-compose-pluginPlugin 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_setupStage : Init (Network)

DirectiveValeurSignification
/dev/sdbLe devicePremier volume additionnel sur Scaleway (le root est /dev/sda)
table_type: gptType de table de partitionsGPT est le standard moderne. MBR est limité à 2 TB
layout: trueSchéma de partitionnementtrue = une seule partition utilisant tout le disque
overwrite: falseProtection des donnéesSi 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)

DirectiveValeurSignification
label: dataLabel du FSUtilisable ensuite via LABEL=data dans fstab
filesystem: ext4Type de filesystemext4 est le choix standard. xfs est préférable pour du stockage très orienté séquentiel/gros fichiers
device: /dev/sdbDevice sourceCloud-init trouvera la partition automatiquement grâce à partition: auto
partition: autoSélection de partitionauto = la première partition sans filesystem existant. Alternatives : none (device brut), any, ou un numéro
overwrite: falseProtectionSi 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 :

PositionChamp fstabValeurRôle
0fs_spec/dev/sdb1Device ou identifiant (UUID, LABEL)
1fs_file/mnt/dataPoint de montage
2fs_vfstypeext4Type de filesystem
3fs_mntopsdefaults,nofail,noatimeOptions de montage
4fs_freq0Dump (0 = pas de dump)
5fs_passno2Ordre de fsck (1 = root, 2 = autres, 0 = pas de check)

Les options de montage sont cruciales :

  • defaults : active rw,suid,dev,exec,auto,nouser,async
  • nofail : indispensable — permet au système de booter même si le volume n’est pas disponible. Sans nofail, un volume détaché = une VM qui ne démarre plus
  • noatime : 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 :

DirectiveValeurRôle
BACKENDsshg-fw-nft-setsUtilise nftables avec des sets IP. C’est le backend recommandé sur Ubuntu 22.04+
LOGREADERjournalctl -afb -p info -n1 -t sshd -o catLit les logs SSH depuis le journal systemd. -f = follow, -t sshd = filtre sur sshd, -o cat = sortie brute
THRESHOLD30Score cumulé déclenchant le blocage (chaque tentative échouée = ~10 points)
BLOCK_TIME120Durée initiale du blocage : 2 minutes. Double à chaque récidive
DETECTION_TIME1800Fenêtre de 30 minutes pour accumuler le score. Après 30 min sans tentative, le score est remis à zéro
WHITELIST_FILE/etc/sshguard/whitelistChemin vers notre fichier de whitelist
BLACKLIST_FILE120:/var/lib/sshguard/blacklist.dbBlacklist 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 nftables supprime 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’option defer reporte l’écriture au stage Final (via cc_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:nginx nécessite que nginx soit installé). Dans notre cas, owner: root:root existe toujours, donc defer n’est pas nécessaire.

La configuration du daemon :

OptionValeurRôle
log-driverjson-fileDriver de log par défaut. Les logs sont stockés dans /var/lib/docker/containers/<id>/<id>-json.log
max-size10mRotation quand un fichier de log atteint 10 Mo
max-file3Garde 3 fichiers de rotation maximum (= 30 Mo max par conteneur)
storage-driveroverlay2Driver 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.

CommandeRôle
mkdir -p /var/lib/sshguardCrée le répertoire pour la blacklist persistante de sshguard
systemctl enable sshguardActive sshguard au boot
systemctl restart sshguardRedémarre sshguard pour charger notre config custom (whitelist + blacklist)
systemctl enable dockerActive Docker au boot (normalement fait par le paquet, mais on s’assure)
systemctl start --no-block dockerDé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.

runcmd reste utile pour des commandes qui ne dépendent pas de paquets installés par packages : 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 :

BlocRôle
entryPoints.webÉcoute sur le port 80. Redirige automatiquement vers HTTPS
entryPoints.websecureÉcoute sur le port 443 (TLS)
providers.dockerDécouvre les conteneurs via le socket Docker. exposedByDefault: false force l’opt-in via le label traefik.enable=true
providers.fileCharge les fichiers YAML dans /dynamic/. watch: true recharge automatiquement quand un fichier change
certificatesResolvers.letsencryptChallenge HTTP-01 sur le port 80. Les certificats sont stockés dans /acme.json
experimental.plugins.souinCharge 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é.

OptionValeurRôle
ttl300sDurée de vie du cache : 5 minutes
stale86400sSert du contenu stale pendant 24h si le backend ne répond pas
allowed_http_verbsGET, HEADSeuls 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-privileges empêche l’escalade de privilèges.
  • ./acme.json : doit avoir les permissions 600 — Traefik refuse de démarrer sinon. Le script post-install.sh s’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é dans providers.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 :

EmplacementRisqueMitigation
User-data de l’instanceLisible 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 TerraformMême marqué sensitive, le PAT est en clair dans le stateChiffrer le state (S3 + SSE, Terraform Cloud), restreindre l’accès
/root/.docker/config.jsonLisible par root sur l’instanceNormal — 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 directeApproche Secret Manager
PAT dans le user-dataOuiNon
PAT dans le state TerraformOui (var.ghcr_pat)Oui (scaleway_secret_version.data)
Credentials dans le user-dataLe PAT lui-mêmeUne clé API Scaleway (scope réduit)
Rotation du PATRedéployer l’instanceCréer une nouvelle version du secret
RévocationRévoquer le PAT sur GitHubRévoquer le PAT ou la clé API
ComplexitéSimplePlus 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 runcmd dans 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.tfvars contient 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

Source : cloud-init debugging


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

Scaleway

Terraform

Docker

SSHGuard

Traefik et Souin

GitHub Container Registry

Scaleway Secret Manager et IAM