Introduction à Terraform avec Docker
Apprendre Terraform sans compte cloud, avec le provider Docker : un premier container, puis PostgreSQL et Adminer, les variables et le cycle plan/apply.
Pour apprendre Terraform, tu n'as pas besoin d'un compte cloud ni d'une carte bancaire. Le provider Docker permet de manipuler des images, des containers et des réseaux en local, avec exactement la même syntaxe et le même cycle plan/apply que sur AWS ou Scaleway. C'est la façon la moins risquée de se faire la main.
Cet article s'adresse à quelqu'un qui connaît Docker et n'a jamais écrit une ligne de Terraform. On lance un premier container, puis une base PostgreSQL avec son interface web, et on croise en chemin tout le vocabulaire : provider, ressource, state, module, variable, output.
Note
J'ai écrit cet article en 2022. Le code a été remis à niveau en novembre 2026 pour Terraform 1.17 et la version 4 du provider Docker.
Installer Terraform
Direction le site officiel pour télécharger le binaire qui correspond à ton système. Il te faut aussi Docker, démarré.
Pour vérifier que tout est bon :
$ terraform --version
Terraform v1.17.x
on darwin_arm64
$ docker infoLa syntaxe en 2 minutes
Terraform utilise son propre langage, HCL. Il y a trois choses à connaître pour lire la suite.
Assigner une valeur :
image_id = "1234"Définir une ressource, c'est-à-dire un objet que Terraform va créer et suivre. Le premier mot entre guillemets est son type, le second le nom que tu lui donnes :
resource "container" "example" {
name = "1234"
network_interface {
# ...
}
}Et les types de valeurs :
"1234" // String
1234 // Number
true / false // Boolean
[0, 5, 2, 4] // List
{ "key" = "value" } // MapC'est tout pour la théorie. Le reste s'apprend en faisant.
Configurer le provider Docker
Un provider, c'est le plugin qui sait parler à une API : celle d'AWS, de Scaleway, ou ici du démon Docker. On va créer deux fichiers :
|-- main.tf
|-- provider.tfDans provider.tf, on déclare le provider et on lui indique où est le socket Docker :
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.0"
}
}
}
provider "docker" {
host = "unix:///var/run/docker.sock"
}Le ~> 4.0 accepte toutes les versions 4.x et refuse la 5 le jour où elle sortira. Une version majeure peut casser ta configuration, autant décider toi-même du moment où tu y passes.
Note
Si Terraform ne trouve pas Docker, lance docker context ls : la colonne DOCKER ENDPOINT donne le chemin du socket à mettre dans host. C'est le cas avec Colima ou Docker en mode rootless, par exemple.
On initialise le projet. Cette commande télécharge le provider dans un dossier .terraform/ et c'est à refaire à chaque fois que tu ajoutes un provider ou un module :
$ terraform initLancer un premier container
Dans main.tf, on définit une image et un container :
resource "docker_image" "nginx" {
name = "nginx:alpine"
}
resource "docker_container" "nginx" {
name = "hello-terraform"
image = docker_image.nginx.image_id
ports {
internal = 80
external = 8000
}
}Ça se lit presque comme une phrase : on pull une image Nginx, puis on crée un container à partir de cette image, avec son port 80 publié sur le port 8000 de ta machine. Remarque le docker_image.nginx.image_id : la deuxième ressource référence la première, et Terraform en déduit tout seul l'ordre de création.
Pour appliquer :
$ terraform applyTerraform te montre ce qu'il va faire et attend un yes. Prends l'habitude de lire cette liste avant de confirmer, même pour deux containers. Sur un vrai cloud, c'est là que tu repères le destroy que tu n'avais pas prévu.
Une fois l'apply terminé, ouvre http://localhost:8000 : la page d'accueil de Nginx s'affiche.
Note
terraform plan te montre les mêmes changements sans rien appliquer. C'est la commande que tu lanceras le plus souvent.
Le fichier state
Après l'apply, un fichier terraform.tfstate est apparu. C'est la mémoire de Terraform : ce qu'il a créé, avec quels identifiants. Au prochain apply, il compare ce fichier avec tes .tf et avec la réalité, puis en déduit quoi créer, modifier ou supprimer.
Tu peux le vérifier tout de suite. Relance terraform plan sans rien toucher : Terraform répond No changes. Supprime le container à la main avec docker rm -f hello-terraform, relance le plan : il propose de le recréer.
Ne touche jamais à ce fichier manuellement. Si tu bosses en équipe, stocke-le sur un backend distant.
Avant de passer à la suite, fais le ménage. La commande supprime tout ce que le state connaît :
$ terraform destroyUn exemple plus concret : PostgreSQL + Adminer
Un Nginx qui sert sa page par défaut, ça ne sert pas à grand-chose. On va monter quelque chose d'utilisable : PostgreSQL et Adminer, une interface web pour parcourir la base. Et on en profite pour découvrir les modules, qui servent à regrouper des ressources qui vont ensemble.
Nouvelle structure :
|-- main.tf
|-- provider.tf
|-- modules
| |-- postgres
| | |-- provider.tf
| | |-- postgres.tf
| | |-- adminer.tfLe module postgres
Dans main.tf, on remplace le Nginx par un appel à notre module :
module "postgres" {
source = "./modules/postgres"
}Le module doit déclarer lui aussi le provider qu'il utilise, sinon Terraform va chercher un hashicorp/docker qui n'existe pas. Seul le bloc required_providers est nécessaire. La configuration (host) reste à la racine et le module en hérite :
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.0"
}
}
}Pense à relancer terraform init quand tu ajoutes un module.
Le container PostgreSQL
resource "docker_image" "postgres" {
name = "postgres:18-alpine"
keep_locally = true
}
resource "docker_container" "postgres" {
name = "postgres"
image = docker_image.postgres.image_id
shm_size = 256
ports {
external = 5432
internal = 5432
}
volumes {
container_path = "/var/lib/postgresql"
host_path = abspath("tmp/postgres")
}
env = [
"POSTGRES_USER=user",
"POSTGRES_PASSWORD=password"
]
}Si tu connais Docker, tu retrouves tes marques : ports pour le mapping, volumes pour la persistance, env pour les variables d'environnement. Le shm_size est moins connu, c'est la mémoire partagée, en mégaoctets. Docker n'en donne que 64 par défaut, et Postgres peut en manquer sur des requêtes parallèles. keep_locally garde l'image sur ta machine après un destroy, ce qui évite de la retélécharger à chaque essai.
Attention
Depuis PostgreSQL 18, l'image officielle range ses données dans /var/lib/postgresql, et plus dans /var/lib/postgresql/data. Si tu montes l'ancien chemin, le container s'arrête au démarrage avec un message d'erreur qui t'explique le changement.
Le container Adminer
resource "docker_image" "adminer" {
name = "adminer:5"
keep_locally = true
}
resource "docker_container" "adminer" {
name = "adminer"
image = docker_image.adminer.image_id
ports {
external = 8080
internal = 8080
}
env = [
"ADMINER_DEFAULT_SERVER=${docker_container.postgres.name}"
]
depends_on = [
docker_container.postgres
]
}Regarde la variable d'environnement : on référence le container Postgres avec docker_container.postgres.name. La syntaxe ${...} insère une valeur dans une string. Cette référence suffit à Terraform pour créer Postgres avant Adminer. Le depends_on est redondant ici, mais je le laisse parce qu'il rend la dépendance visible pour quelqu'un qui lit le fichier en diagonale.
Si tu appliques maintenant, les deux containers démarrent et Adminer répond sur http://localhost:8080. Mais il ne trouvera pas le serveur postgres : sur le réseau par défaut de Docker, les containers ne se voient pas par leur nom. Il leur faut un réseau dédié. On va le créer à la racine du projet et le transmettre au module, ce qui tombe bien : c'est exactement à ça que servent les variables.
Faire entrer des valeurs dans le module : les variables
Un module est une boîte fermée. Les variables y font entrer des valeurs, et c'est ce qui le rend réutilisable.
Dans main.tf, on crée un réseau et on passe son nom au module :
resource "docker_network" "default" {
name = "local"
}
module "postgres" {
source = "./modules/postgres"
network_name = docker_network.default.name
}Dans le module, crée variables.tf :
variable "network_name" {
type = string
}Et utilise-la dans les deux containers. Dans postgres.tf :
resource "docker_container" "postgres" {
name = "${var.network_name}-postgres"
# ... reste de la config ...
networks_advanced {
name = var.network_name
}
}Puis dans adminer.tf :
resource "docker_container" "adminer" {
name = "${var.network_name}-adminer"
# ... reste de la config ...
networks_advanced {
name = var.network_name
}
}On en profite pour préfixer le nom des containers avec celui du réseau. Tu pourrais appeler le module une deuxième fois avec un autre réseau sans collision de noms (il faudrait aussi rendre les ports paramétrables, mais tu as compris le principe).
Faire sortir des valeurs : les outputs
De l'extérieur, tu ne vois pas les ressources d'un module. Pour en sortir une valeur, il faut la déclarer dans un output. C'est le chemin inverse des variables :
output "postgres_container_name" {
value = docker_container.postgres.name
}
output "adminer_url" {
value = "http://localhost:${docker_container.adminer.ports[0].external}"
}Ces valeurs sont accessibles depuis la racine via module.postgres.postgres_container_name et module.postgres.adminer_url. Pour que Terraform affiche l'URL à la fin de l'apply, on la fait remonter d'un cran dans main.tf :
output "adminer_url" {
value = module.postgres.adminer_url
}Un dernier terraform apply :
$ terraform apply
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
Outputs:
adminer_url = "http://localhost:8080"Ouvre l'URL et connecte-toi : système PostgreSQL, serveur local-postgres (déjà rempli), utilisateur user, mot de passe password. Tu es dans une base créée de bout en bout par Terraform. Quand tu as fini, terraform destroy supprime les containers et le réseau. Les données restent dans tmp/postgres.
Ce que tu sais faire maintenant
Avec providers, ressources, state, modules, variables et outputs, tu as les bases de Terraform sans avoir dépensé un centime. Le reste dépend surtout du provider : les ressources changent de nom, le cycle init, plan, apply, destroy reste le même.
Quand tu seras à l'aise, la suite logique est de refaire la même chose sur un vrai serveur. C'est ce que je fais dans l'article sur le déploiement d'infrastructure Scaleway avec TerraformInfrastructure Scaleway avec Terraform · partie 215 minDéployer une infra Scaleway avec TerraformDé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.Lire l'articleNouvel ongletUn bandeau t'y proposera de reprendre ici, section 8..
Infrastructure Scaleway avec Terraform · partie 1 sur 3
La suite, partie 2Dé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.
15 min de lecture
À lire ensuite
Déployer avec Compose, Traefik et Let's Encrypt
On installe Traefik en reverse proxy avec Docker Compose, des certificats Let's Encrypt renouvelés tout seuls, puis une application et sa base de données.
Le DevOps n'est pas un métier
Le DevOps est une façon de travailler en équipe, née en 2009, et les offres d'emploi en ont fait un poste à pourvoir. D'où vient le mot, et ce que j'en retiens.
Agents IA : boucle agentique et pattern ReAct
Construis un agent IA autonome en TypeScript avec Mistral : boucle agentique, pattern ReAct, planification, mémoire et garde-fous. Code complet inclus.