# Déployer une infra Scaleway avec Terraform

Déploie un serveur Scaleway avec Terraform de zéro : projet, clé SSH, security group, instance et cloud-init. Guide pas à pas avec estimation des coûts.

Source : https://simotech.dev/blog/deployer-infrastructure-scaleway-terraform

Dans mon [article précédent](/blog/introduction-terraform-docker), on a découvert Terraform avec Docker en local. C'était pratique pour apprendre sans payer, mais à un moment il faut un vrai serveur. On va en créer un sur Scaleway, entièrement décrit en Terraform, jusqu'à la clé SSH pour s'y connecter.

L'article s'adresse à quelqu'un qui a déjà lancé un `terraform apply` et qui veut passer à un vrai cloud. Compte une douzaine d'euros par mois si tu laisses tourner le serveur, quelques centimes si tu le détruis à la fin.

## Pourquoi Scaleway

Un cloud provider français, avec des datacenters à Paris. Pour un projet perso ou une petite prod, ça veut dire une latence faible depuis la France et des données hébergées dans l'Union européenne. Une instance DEV1-S (2 vCPU, 2 Go de RAM) coûte environ 6,50 € HT par mois, ce qui suffit largement pour héberger quelques containers. Et le provider Terraform est bien maintenu, ce qui n'est pas le cas chez tous les hébergeurs de cette taille.

## Ce qu'on va construire

À la fin de cet article, tu auras déployé :

- Un projet Scaleway pour isoler tes ressources
- Une clé SSH générée automatiquement par Terraform
- Un Security Group avec règles firewall
- Une instance DEV1-S avec Ubuntu 24.04, Docker déjà installé

```mermaid
flowchart TD
    P[Projet Scaleway] --> K[Clé SSH]
    P --> SG[Security Group]
    P --> IP[IP publique]
    K --> S[Instance DEV1-S]
    SG --> S
    IP --> S
    CI[cloud-init] --> S
```

## Prérequis

- Terraform 1.4 ou plus récent
- Un compte Scaleway ([console.scaleway.com](https://console.scaleway.com))
- Tes credentials API Scaleway (Access Key + Secret Key)

Pour créer tes credentials, rends-toi dans la console Scaleway : IAM > API Keys > Generate API Key. Note bien la secret key au moment de la création, elle ne sera plus affichée ensuite.

## Architecture des fichiers

```
infra/
├── main.tf           # Ressources principales
├── variables.tf      # Définition des variables
├── outputs.tf        # Valeurs de sortie
├── versions.tf       # Configuration Terraform et providers
└── terraform.tfvars  # Valeurs des variables (non committé)
```

## Configuration du provider

### versions.tf

On commence par dire à Terraform quels providers on utilise et dans quelles versions. Bloquer les versions majeures évite les mauvaises surprises au prochain `terraform init` :

```terraform title="versions.tf"
terraform {
  required_version = ">= 1.4"

  required_providers {
    scaleway = {
      source  = "scaleway/scaleway"
      version = "~> 2.0"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
    local = {
      source  = "hashicorp/local"
      version = "~> 2.0"
    }
    http = {
      source  = "hashicorp/http"
      version = "~> 3.0"
    }
  }
}
```

Quatre providers, dont trois qui ne touchent pas au cloud : `scaleway/scaleway` pour les ressources, `hashicorp/tls` pour générer la clé SSH, `hashicorp/local` pour l'écrire sur le disque, et `hashicorp/http` pour récupérer ton IP publique. On verra plus bas pourquoi on en a besoin.

Le `required_version` vaut `>= 1.4` parce qu'on utilise plus loin la ressource `terraform_data`, arrivée avec Terraform 1.4.

### variables.tf

Les variables, c'est tout ce qui change d'un déploiement à l'autre : credentials, nom du projet, taille de l'instance.

```terraform title="variables.tf"
# Credentials Scaleway
variable "scw_access_key" {
  description = "Scaleway Access Key"
  type        = string
  sensitive   = true
}

variable "scw_secret_key" {
  description = "Scaleway Secret Key"
  type        = string
  sensitive   = true
}

variable "scw_organization_id" {
  description = "Scaleway Organization ID"
  type        = string
}

# Configuration
variable "project_name" {
  description = "Nom du projet Scaleway"
  type        = string
  default     = "mon-projet"
}

variable "region" {
  description = "Scaleway region"
  type        = string
  default     = "fr-par"
}

variable "zone" {
  description = "Scaleway zone"
  type        = string
  default     = "fr-par-1"
}

variable "instance_type" {
  description = "Type d'instance (DEV1-S, DEV1-M, PLAY2-NANO...)"
  type        = string
  default     = "DEV1-S"
}
```

L'attribut `sensitive = true` empêche Terraform d'afficher ces valeurs dans la sortie du plan et de l'apply. Elles restent en clair dans le state, par contre. Garde ça en tête pour la suite.

Un mot sur region et zone, parce que la distinction n'est pas évidente au début. Chez Scaleway, une région (`fr-par`) contient plusieurs zones (`fr-par-1`, `fr-par-2`, `fr-par-3`). Les ressources sont créées dans une zone précise.

### terraform.tfvars

Crée ce fichier pour stocker tes valeurs :

```terraform title="terraform.tfvars"
scw_access_key      = "SCWXXXXXXXXXXXXXXXXX"
scw_secret_key      = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
scw_organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
project_name        = "mon-super-projet"
```

> [!WARNING]
> Ce fichier contient tes secrets, il ne doit jamais finir dans git. Ajoute `terraform.tfvars`, `*.auto.tfvars` et `*_ed25519` à ton `.gitignore` tout de suite, avant le premier commit. C'est le genre de truc qu'on remet à plus tard et qu'on regrette.

## Créer le projet et configurer le provider

Tout ce qui suit va dans `main.tf`, bloc après bloc.

```terraform title="main.tf"
# Configuration du provider
provider "scaleway" {
  access_key      = var.scw_access_key
  secret_key      = var.scw_secret_key
  organization_id = var.scw_organization_id
  region          = var.region
  zone            = var.zone
}

# Créer un projet pour isoler les ressources
resource "scaleway_account_project" "main" {
  name        = var.project_name
  description = "Projet géré par Terraform"
}
```

Un projet Scaleway, c'est un compartiment : ses ressources, ses permissions, sa facture. Un projet par environnement (dev, staging, prod) te permet de tout détruire d'un côté sans risquer de toucher à l'autre.

## Générer une clé SSH automatiquement

Tu pourrais créer une clé SSH à la main et coller la partie publique dans la console. Mais si Terraform peut la générer, l'enregistrer chez Scaleway et l'écrire sur ton disque, autant lui laisser faire. Tout reste dans le même `apply` :

```terraform title="main.tf"
# Générer une paire de clés ED25519
resource "tls_private_key" "ssh" {
  algorithm = "ED25519"
}

# Enregistrer la clé publique dans Scaleway
resource "scaleway_iam_ssh_key" "main" {
  name       = "${var.project_name}-terraform"
  public_key = trimspace(tls_private_key.ssh.public_key_openssh)
  project_id = scaleway_account_project.main.id
}

# Sauvegarder la clé privée localement
resource "local_sensitive_file" "ssh_private_key" {
  content         = tls_private_key.ssh.private_key_openssh
  filename        = "${path.module}/${var.project_name}_ed25519"
  file_permission = "0600"
}

# Sauvegarder la clé publique localement
resource "local_file" "ssh_public_key" {
  content  = tls_private_key.ssh.public_key_openssh
  filename = "${path.module}/${var.project_name}_ed25519.pub"
}
```

ED25519 plutôt que RSA : des clés plus courtes, plus rapides, et au moins aussi sûres. Le `trimspace()` enlève le retour à la ligne final que `public_key_openssh` ajoute, pour ne pas envoyer une clé avec un caractère parasite à Scaleway. Et `file_permission = "0600"` n'est pas optionnel : SSH refuse d'utiliser une clé privée lisible par d'autres utilisateurs. `path.module` désigne simplement le dossier où sont tes fichiers Terraform.

La clé privée passe par `local_sensitive_file` plutôt que `local_file` : même comportement, mais son contenu n'apparaît jamais dans la sortie du plan.

> [!WARNING]
> La clé privée se retrouve aussi dans le state, en clair. Si tu partages ce state avec une équipe, tout le monde peut se connecter au serveur.

## Configurer le Security Group

Un **Security Group**, c'est le firewall de ton instance, géré côté Scaleway avant même que le trafic n'atteigne la machine. On bloque tout en entrée, on autorise SSH uniquement depuis ton IP, et on ouvre les ports 80 et 443 pour le web.

```terraform title="main.tf"
# Récupérer notre IP publique actuelle
data "http" "my_ip" {
  url = "https://ipv4.icanhazip.com"
}

locals {
  my_ip = "${chomp(data.http.my_ip.response_body)}/32"
}

# Créer le Security Group
resource "scaleway_instance_security_group" "main" {
  project_id              = scaleway_account_project.main.id
  name                    = "${var.project_name}-sg"
  description             = "Security group pour ${var.project_name}"
  inbound_default_policy  = "drop"   # Bloquer tout par défaut
  outbound_default_policy = "accept" # Autoriser les sorties

  # SSH - restreint à notre IP uniquement
  inbound_rule {
    action   = "accept"
    port     = 22
    protocol = "TCP"
    ip_range = local.my_ip
  }

  # HTTP - ouvert à tous
  inbound_rule {
    action   = "accept"
    port     = 80
    protocol = "TCP"
  }

  # HTTPS - ouvert à tous
  inbound_rule {
    action   = "accept"
    port     = 443
    protocol = "TCP"
  }
}
```

C'est là qu'intervient le provider `http` : le data source appelle un service qui renvoie ton IP publique, `chomp()` enlève le retour à la ligne, et le `/32` en fait un CIDR d'une seule adresse. Résultat, le port 22 n'est ouvert que pour toi.

Le revers : si ton IP change (box qui redémarre, 4G, VPN), le prochain `terraform apply` mettra la règle à jour, mais en attendant tu es dehors. Pour une machine que tu gères seul, je trouve que ça vaut le coup. Pour une équipe, prévois une règle par personne ou un bastion.

Le reste suit le principe du deny by default : on bloque tout, puis on ouvre explicitement ce dont on a besoin.

## Déployer l'instance

### Réserver une IP publique

```terraform title="main.tf"
resource "scaleway_instance_ip" "main" {
  project_id = scaleway_account_project.main.id
}
```

On réserve l'IP comme une ressource à part, et on l'attache ensuite à l'instance. Comme ça, si tu détruis et recrées le serveur, l'IP reste la même et ton DNS ne bouge pas. Ça arrive à chaque modification du cloud-init, on le verra juste après.

### Créer l'instance

```terraform title="main.tf"
resource "scaleway_instance_server" "main" {
  project_id = scaleway_account_project.main.id
  name       = "${var.project_name}-server"
  type       = var.instance_type
  image      = "ubuntu_noble"

  # Attacher l'IP et le Security Group
  ip_id             = scaleway_instance_ip.main.id
  security_group_id = scaleway_instance_security_group.main.id

  tags = ["terraform", "production"]

  # La clé SSH doit exister avant le premier démarrage
  depends_on = [scaleway_iam_ssh_key.main]
}
```

Le `depends_on` mérite un mot. Scaleway installe les clés SSH du projet sur l'instance au moment où elle démarre. Or rien dans ce bloc ne référence la clé, donc Terraform pourrait créer le serveur en parallèle, avant qu'elle soit enregistrée. On lui impose l'ordre à la main.

Pour choisir le type d'instance, voici quelques repères. Les prix sont ceux du catalogue Scaleway en novembre 2026 pour `fr-par-1`, hors taxes, pour un mois complet de 730 heures, sans le stockage ni l'IP :

| Type | vCPU | RAM | Prix/mois |
|------|------|-----|-----------|
| DEV1-S | 2 | 2 Go | ~6,50 € |
| DEV1-M | 3 | 4 Go | ~14,70 € |
| DEV1-L | 4 | 8 Go | ~31,30 € |
| PLAY2-NANO | 2 | 4 Go | ~20,10 € |
| GP1-XS | 4 | 16 Go | ~67,80 € |

Les DEV1 sont une gamme de première génération, toujours en vente et moins chère que les PLAY2 qui lui ont succédé. La facturation se fait à l'heure : un serveur détruit au bout d'un après-midi coûte quelques centimes.

Côté images, tu as `ubuntu_noble` (24.04), `ubuntu_resolute` (26.04), `ubuntu_jammy` (22.04), `debian_trixie`, `debian_bookworm`, entre autres.

## Provisionner avec Cloud-init

À ce stade tu as un serveur vide. **Cloud-init** est l'outil, présent sur les images cloud, qui configure la machine au premier démarrage : installer des packages, créer des fichiers, lancer des commandes. L'intérêt, c'est que tout est décrit dans ton dépôt. Tu ne te connectes jamais en SSH pour installer Docker à la main.

### Version simple (inline)

```terraform title="main.tf"
resource "scaleway_instance_server" "main" {
  # ... configuration précédente ...

  user_data = {
    cloud-init = <<-EOF
      #cloud-config
      package_update: true
      package_upgrade: true

      packages:
        - ca-certificates
        - curl
        - git
        - htop
        - docker.io
        - docker-compose-v2

      runcmd:
        - systemctl enable --now docker
    EOF
  }
}
```

Les paquets `docker.io` et `docker-compose-v2` viennent des dépôts d'Ubuntu. Le second apporte la commande `docker compose`, dont on aura besoin dans la suite de la série.

> [!WARNING]
> Cloud-init ne tourne qu'au premier démarrage. Si tu modifies le `user_data` d'une instance qui existe déjà, rien ne se passe. C'est le piège classique, et c'est pour ça que la version suivante force la recréation de l'instance quand le contenu change.

### Version avec fichier template

Dès que le cloud-init dépasse quelques lignes, un fichier séparé est plus lisible, et tu peux y injecter des variables :

```terraform title="main.tf"
locals {
  cloud_init_content = templatefile("${path.module}/templates/cloud-init.yml", {
    project_name = var.project_name
  })
}

resource "scaleway_instance_server" "main" {
  # ... configuration précédente ...

  user_data = {
    cloud-init = local.cloud_init_content
  }

  # Recréer l'instance si cloud-init change
  lifecycle {
    replace_triggered_by = [terraform_data.cloud_init_hash]
  }
}

# Hash du cloud-init pour détecter les changements
resource "terraform_data" "cloud_init_hash" {
  input = md5(local.cloud_init_content)
}
```

Et dans `templates/cloud-init.yml` :

```yaml title="templates/cloud-init.yml"
#cloud-config
package_update: true
package_upgrade: true

packages:
  - ca-certificates
  - curl
  - git
  - htop
  - docker.io
  - docker-compose-v2

runcmd:
  - systemctl enable --now docker
  - echo "Provisioning complete for ${project_name}"
```

La fonction `templatefile()` remplace les `${...}` du YAML par les valeurs que tu lui passes. Le `terraform_data` avec le hash md5, lui, est le déclencheur : si le contenu du cloud-init change, le hash change, et `replace_triggered_by` recrée l'instance. Ça détruit la machine et son disque, donc réfléchis avant de modifier le template en prod.

## Outputs

Les outputs, c'est ce que Terraform t'affiche à la fin de l'apply. Je mets toujours la commande SSH complète dedans, ça évite d'aller chercher l'IP et le chemin de la clé :

```terraform title="outputs.tf"
output "project_id" {
  description = "ID du projet Scaleway"
  value       = scaleway_account_project.main.id
}

output "instance_public_ip" {
  description = "IP publique de l'instance"
  value       = scaleway_instance_ip.main.address
}

output "ssh_command" {
  description = "Commande SSH pour se connecter"
  value       = "ssh -i ${local_sensitive_file.ssh_private_key.filename} root@${scaleway_instance_ip.main.address}"
}
```

Après `terraform apply`, tu peux afficher ces valeurs avec :

```bash
terraform output
```

## Déployer l'infrastructure

### Initialiser Terraform

```bash
$ terraform init

Initializing the backend...
Initializing provider plugins...
- Finding scaleway/scaleway versions matching "~> 2.0"...
- Installing scaleway/scaleway v2.x.x...

Terraform has been successfully initialized!
```

### Prévisualiser les changements

```bash
$ terraform plan

Plan: 9 to add, 0 to change, 0 to destroy.
```

Neuf ressources avec la version template : le projet, la paire de clés, son enregistrement chez Scaleway, les deux fichiers locaux, le Security Group, l'IP, le hash du cloud-init et l'instance.

Prends le temps de lire le plan. Un `destroy` qui se glisse dans la liste quand tu pensais juste modifier un tag, ça arrive, et c'est le seul moment où tu peux l'attraper.

### Appliquer

```bash
$ terraform apply

Do you want to perform these actions?
  Enter a value: yes

Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Outputs:

instance_public_ip = "51.15.xxx.xxx"
ssh_command = "ssh -i ./mon-projet_ed25519 root@51.15.xxx.xxx"
```

### Se connecter à l'instance

```bash
$ ssh -i ./mon-projet_ed25519 root@51.15.xxx.xxx

Welcome to Ubuntu 24.04.x LTS
root@mon-projet-server:~#
```

L'apply rend la main dès que l'instance a démarré, alors que cloud-init travaille encore : la mise à jour des paquets et l'installation de Docker prennent quelques minutes. Pour savoir où il en est, une fois connecté :

```bash
$ cloud-init status --wait
status: done

$ docker compose version
```

## Partager le state avec HCP Terraform

Tant que tu es seul sur ton poste, le state en local suffit. Dès que tu travailles à deux ou depuis une CI, il faut le partager, et le mettre dans git est une mauvaise idée (il contient des secrets, on l'a vu). HCP Terraform, le service qui s'appelait Terraform Cloud jusqu'en 2024, règle ça gratuitement jusqu'à 500 ressources gérées :

```terraform title="versions.tf"
terraform {
  cloud {
    organization = "mon-organisation"

    workspaces {
      name = "mon-projet-prod"
    }
  }

  # ... required_providers ...
}
```

Le state ne traîne plus sur un disque local : tout le monde voit le même état, un verrou empêche deux `apply` en même temps, et tu gardes l'historique des versions.

Connecte-toi avec :

```bash
$ terraform login
$ terraform init
```

## Quelques habitudes qui aident

### 1. Structure des fichiers

Un seul `main.tf` suffit pour cet article. Dès que ça grossit, découper par thème rend les diffs plus lisibles :

```
infra/
├── main.tf           # Provider et ressources principales
├── network.tf        # Security groups, VPC
├── compute.tf        # Instances
├── variables.tf
├── outputs.tf
├── versions.tf
└── templates/
    └── cloud-init.yml
```

### 2. Nommage cohérent

Préfixe tes ressources avec le nom du projet. Dans la console Scaleway, quand tu as trois projets, un serveur qui s'appelle `server` ne t'aide pas :

```terraform
resource "scaleway_instance_server" "main" {
  name = "${var.project_name}-server"
}
```

### 3. Utilise des tags

Les tags facilitent le filtrage et la gestion des coûts :

```terraform
tags = ["terraform", "production", var.project_name]
```

### 4. Ne jamais modifier le state manuellement

Si une ressource existe déjà chez Scaleway et que tu veux que Terraform la gère, passe par `import` plutôt que d'éditer le fichier :

```bash
terraform import scaleway_instance_server.main fr-par-1/instance-id
```

### 5. Détruire proprement

L'avantage d'avoir tout en Terraform, c'est que tout disparaît aussi proprement que c'est arrivé. Pas d'IP oubliée qui continue à être facturée :

```bash
$ terraform destroy
```

Pour supprimer une ressource spécifique :

```bash
$ terraform destroy -target=scaleway_instance_server.main
```

## Ce que ça coûte

Scaleway facture séparément l'instance, son IPv4 publique et son stockage. Pour l'infrastructure décrite dans cet article, aux tarifs de novembre 2026, hors taxes :

| Ressource | Coût mensuel |
|-----------|--------------|
| Instance DEV1-S (0,009 €/h) | ~6,50 € |
| IPv4 flexible (0,005 €/h) | ~3,65 € |
| Stockage, pour 20 Go, selon le type de volume | ~0,70 à 1,90 € |
| **Total** | **~11 à 12 €/mois** |

L'IPv4 n'est pas gratuite, même attachée à une instance. Elle est facturée de sa réservation à sa suppression, que le serveur tourne ou non. Même chose pour le volume. Les tarifs bougent, vérifie-les sur la [page de prix de Scaleway](https://www.scaleway.com/en/pricing/) avant de te lancer.

> [!WARNING]
> Éteindre l'instance n'arrête pas toute la facture : l'IP et le volume continuent d'être comptés. C'est aussi ce qui se passe si tu détruis seulement l'instance avec `-target` et que tu laisses l'IP derrière. Pour ne plus rien payer, il faut un `terraform destroy` complet.

## Pour aller plus loin

- [Documentation du provider Scaleway](https://registry.terraform.io/providers/scaleway/scaleway/latest/docs)
- [Documentation Scaleway](https://www.scaleway.com/en/docs/)
- [HCP Terraform](https://app.terraform.io/)

Tu as maintenant un serveur que tu peux recréer à l'identique en une commande. Fini la machine configurée à la main un soir, dont personne ne sait plus ce qu'il y a dessus. Il lui manque encore quelque chose à servir : dans la partie suivante, on y [déploie une application avec Docker Compose, Traefik et Let's Encrypt](/blog/deployer-application-docker-compose-traefik-letsencrypt).
