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.

Publié le Mis à jour le , mis à jour le 9 min de lecture

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 :

Terminal
$ terraform --version
Terraform v1.17.x
on darwin_arm64

$ docker info

La syntaxe en 2 minutes

Terraform utilise son propre langage, HCL. Il y a trois choses à connaître pour lire la suite.

Assigner une valeur :

Terraform
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 :

Terraform
resource "container" "example" {
    name = "1234"
    network_interface {
        # ...
    }
}

Et les types de valeurs :

Terraform
"1234"                      // String
1234                        // Number
true / false                // Boolean
[0, 5, 2, 4]                // List
{ "key" = "value" }         // Map

C'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.tf

Dans provider.tf, on déclare le provider et on lui indique où est le socket Docker :

provider.tf
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 :

Terminal
$ terraform init

Lancer un premier container

Dans main.tf, on définit une image et un container :

main.tf
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 :

Terminal
$ terraform apply

Terraform 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 :

Terminal
$ terraform destroy

Un 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.tf

Le module postgres

Dans main.tf, on remplace le Nginx par un appel à notre module :

main.tf
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 :

modules/postgres/provider.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.0"
    }
  }
}

Pense à relancer terraform init quand tu ajoutes un module.

Le container PostgreSQL

modules/postgres/postgres.tf
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

modules/postgres/adminer.tf
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 :

main.tf
resource "docker_network" "default" {
  name = "local"
}

module "postgres" {
  source       = "./modules/postgres"
  network_name = docker_network.default.name
}

Dans le module, crée variables.tf :

modules/postgres/variables.tf
variable "network_name" {
  type = string
}

Et utilise-la dans les deux containers. Dans postgres.tf :

modules/postgres/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 :

modules/postgres/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 :

modules/postgres/outputs.tf
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 :

main.tf
output "adminer_url" {
  value = module.postgres.adminer_url
}

Un dernier terraform apply :

Terminal
$ 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

Voir en Markdown

À lire ensuite

Infrastructure Scaleway avec Terraform · 3

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.

DevOps

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.

IA pour les développeurs · 2

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.

Alexandre Simonet, développeur fullstack.
J'écris ici sur les serveurs MCP, les agents et l'infra pour les faire tourner.