# 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.

Source : https://simotech.dev/blog/introduction-terraform-docker

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](https://developer.hashicorp.com/terraform/install) 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 :

```bash
$ 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 :

```terraform title="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 :

```bash
$ terraform init
```

## Lancer un premier container

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

```terraform title="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 :

```bash
$ 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](https://developer.hashicorp.com/terraform/language/backend).

Avant de passer à la suite, fais le ménage. La commande supprime tout ce que le state connaît :

```bash
$ 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 :

```terraform title="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 :

```terraform title="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

```terraform title="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.

> [!WARNING]
> 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

```terraform title="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 :

```terraform title="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` :

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

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

```terraform title="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` :

```terraform title="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 :

```terraform title="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` :

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

Un dernier `terraform apply` :

```bash
$ 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 Terraform](/blog/deployer-infrastructure-scaleway-terraform).
