📍 Article principal : Ansible 2026 : la stack pratique pour automatiser Linux et Windows
Vous avez un serveur durci et une stack web maîtrisée. On déploie maintenant une application Node.js gérée par systemd, derrière Nginx — sans PM2, sans hack.
Ce que vous aurez à la fin
Une application Node.js (Fastify, Express, Next.js custom server) déployée par Ansible : code récupéré depuis Git, dépendances installées, build exécuté côté serveur, unité systemd qui supervise le processus, redémarrage automatique en cas de crash, logs centralisés via journald, reverse proxy Nginx avec TLS, déploiement rolling qui ne coupe pas le service. Le tout reproductible et idempotent.
Prérequis
- ansible-core 2.20 et un projet structuré en rôles.
- Un serveur Debian 12 ou Ubuntu 24.04 LTS, durci (idéalement avec le rôle de durcissement de la session précédente).
- Un dépôt Git d’application Node.js avec
package.jsonet un scriptstartqui lance le serveur. - Niveau attendu : connaître Node.js, npm, et le concept de service système.
- Temps : 90 minutes en lecture active.
Étape 1 — Pourquoi systemd plutôt que PM2 ou forever
L’écosystème Node.js a longtemps poussé des supervisors écrits en Node lui-même : forever, pm2, nodemon. Ces outils ont leur place pendant le développement, mais en production sur Linux ils dupliquent ce que systemd fait déjà mieux. Systemd gère le démarrage automatique au boot, la supervision avec relance en cas de crash, l’isolation par cgroup, les limites de ressources, le journal centralisé, les hooks de notification — tout cela sans dépendance externe et en utilisant la même interface (systemctl) que pour Nginx ou MariaDB.
L’argument décisif : votre Ops sait déjà lire journalctl -u monapp. Il ne sait pas forcément utiliser PM2. Réduire la surface cognitive de la prod paie chaque fois qu’on doit déboguer à 3 heures du matin.
Étape 2 — Le rôle nodejs_app
On crée roles/nodejs_app avec ansible-galaxy init. Les variables par défaut dans defaults/main.yml :
---
# Identité de l'application
nodejs_app_name: monapp
nodejs_app_user: monapp
nodejs_app_group: monapp
nodejs_app_root: "/opt/{{ nodejs_app_name }}"
nodejs_app_log_dir: "/var/log/{{ nodejs_app_name }}"
# Code source
nodejs_app_repo: "" # à surcharger : "git@github.com:org/app.git"
nodejs_app_branch: "main"
nodejs_app_deploy_key_path: "/etc/ssh/keys/{{ nodejs_app_name }}-deploy"
# Runtime
nodejs_version_major: "22" # 22 (Maintenance LTS) ou 24 (Active LTS) recommandés en 2026
nodejs_app_env: production
nodejs_app_port: 3000
nodejs_app_extra_env: {} # surchargeable : { DATABASE_URL: "...", REDIS_URL: "..." }
# Build
nodejs_app_install_command: "npm ci --omit=dev"
nodejs_app_build_command: "npm run build"
nodejs_app_start_command: "node dist/server.js"
# Reverse proxy
nodejs_app_domain: "" # à surcharger
nodejs_app_nginx_extra: "" # paramètres Nginx supplémentaires éventuels
Cette API encadre l’utilisateur du rôle sans le brider. Les commandes (install, build, start) sont surchargeables — Next.js voudra npm run build + npm start, une API Fastify préférera juste node dist/server.js, un projet TypeScript pourra utiliser tsx.
Étape 3 — Installer Node.js depuis le dépôt officiel NodeSource
Le paquet nodejs de Debian 12 fige la version sur une LTS ancienne. Pour une version récente — Node.js 22 (Maintenance LTS, support jusqu’en avril 2027) ou 24 (Active LTS, première sortie en mai 2025, support jusqu’en avril 2028) — on utilise le dépôt NodeSource qui distribue les LTS officiels. Dans tasks/runtime.yml :
---
- name: S'assurer que /usr/share/keyrings existe
ansible.builtin.file:
path: /usr/share/keyrings
state: directory
owner: root
group: root
mode: '0755'
- name: Récupérer et installer la clé GPG NodeSource (déchiffrée)
ansible.builtin.shell:
cmd: >
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key
| gpg --dearmor -o /usr/share/keyrings/nodesource.gpg
creates: /usr/share/keyrings/nodesource.gpg
- name: Poser la source NodeSource au format DEB822
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/nodesource.sources
owner: root
group: root
mode: '0644'
content: |
Types: deb
URIs: https://deb.nodesource.com/node_{{ nodejs_version_major }}.x
Suites: nodistro
Components: main
Architectures: amd64 arm64
Signed-By: /usr/share/keyrings/nodesource.gpg
- name: Installer Node.js et build essentials
ansible.builtin.apt:
name:
- "nodejs"
- build-essential
- git
state: present
update_cache: true
Cette séquence reproduit fidèlement ce que fait le script officiel https://deb.nodesource.com/setup_22.x : la clé GPG en clair (binaire .gpg, pas armored .asc) sous /usr/share/keyrings/nodesource.gpg, et la source au format DEB822 dans /etc/apt/sources.list.d/nodesource.sources. Le composant nodistro est unique côté NodeSource depuis 2023 — un seul nom qui sert toutes les distributions Debian et Ubuntu supportées. Le paquet build-essential est requis pour les modules npm qui contiennent du natif (bcrypt, sharp, etc.).
Étape 4 — Utilisateur dédié et permissions
On ne fait jamais tourner une application en root. On crée un utilisateur système non interactif, propriétaire du code et du runtime :
# tasks/user.yml
---
- name: Créer le groupe applicatif
ansible.builtin.group:
name: "{{ nodejs_app_group }}"
state: present
system: true
- name: Créer l'utilisateur applicatif
ansible.builtin.user:
name: "{{ nodejs_app_user }}"
group: "{{ nodejs_app_group }}"
home: "{{ nodejs_app_root }}"
shell: /usr/sbin/nologin
system: true
create_home: false
- name: Créer les répertoires applicatifs
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ nodejs_app_user }}"
group: "{{ nodejs_app_group }}"
mode: '0755'
loop:
- "{{ nodejs_app_root }}"
- "{{ nodejs_app_log_dir }}"
Le shell nologin empêche un attaquant qui aurait l’utilisateur d’ouvrir une session SSH directe. Le drapeau system: true assigne un UID en dessous de 1000 et exclut l’utilisateur des outils de gestion d’utilisateurs réguliers.
Étape 5 — Récupérer et bâtir le code
On déploie le code sous l’utilisateur applicatif. La clé de déploiement Git permet l’accès en lecture au dépôt sans exposer une clé personnelle :
# tasks/deploy.yml
---
- name: Cloner ou mettre à jour le code applicatif
ansible.builtin.git:
repo: "{{ nodejs_app_repo }}"
dest: "{{ nodejs_app_root }}/current"
version: "{{ nodejs_app_branch }}"
accept_newhostkey: true
key_file: "{{ nodejs_app_deploy_key_path }}"
become: true
become_user: "{{ nodejs_app_user }}"
register: git_result
- name: Installer les dépendances npm
ansible.builtin.command:
cmd: "{{ nodejs_app_install_command }}"
chdir: "{{ nodejs_app_root }}/current"
become: true
become_user: "{{ nodejs_app_user }}"
when: git_result.changed
notify: restart nodejs app
- name: Lancer le build
ansible.builtin.command:
cmd: "{{ nodejs_app_build_command }}"
chdir: "{{ nodejs_app_root }}/current"
become: true
become_user: "{{ nodejs_app_user }}"
environment:
NODE_ENV: "{{ nodejs_app_env }}"
when: git_result.changed
notify: restart nodejs app
Trois précautions importantes ici. La directive become_user n’agit que si become: true est posé en parallèle — sinon elle est silencieusement ignorée. La directive accept_newhostkey: true remplace l’ancienne accept_hostkey: true : elle utilise StrictHostKeyChecking=accept-new qui accepte une nouvelle empreinte mais refuse une empreinte qui aurait changé (protection contre le détournement DNS), là où l’ancienne désactivait simplement la vérification. La condition when: git_result.changed est ce qui rend le déploiement idempotent : si le code n’a pas bougé, on n’exécute pas npm ci ni npm run build — qui sont chacun des opérations de plusieurs minutes.
Étape 6 — L’unité systemd
L’unité systemd est le cœur du dispositif. On la dépose via un gabarit dans templates/nodejs-app.service.j2 :
[Unit]
Description={{ nodejs_app_name }} (Node.js)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ nodejs_app_user }}
Group={{ nodejs_app_group }}
WorkingDirectory={{ nodejs_app_root }}/current
Environment=NODE_ENV={{ nodejs_app_env }}
Environment=PORT={{ nodejs_app_port }}
{% for key, value in nodejs_app_extra_env.items() %}
Environment={{ key }}={{ value }}
{% endfor %}
ExecStart=/usr/bin/{{ nodejs_app_start_command }}
Restart=always
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier={{ nodejs_app_name }}
# Durcissement
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={{ nodejs_app_log_dir }} {{ nodejs_app_root }}/current
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
Quatre éléments méritent l’attention. Le Restart=always avec RestartSec=5 redémarre le processus en cas de crash, après cinq secondes — assez pour qu’une boucle de crash ne sature pas le CPU. Le StandardOutput=journal renvoie les logs dans journalctl, accessibles avec journalctl -u monapp -f. Les directives de durcissement (NoNewPrivileges, ProtectSystem, PrivateTmp) sont les protections systemd par défaut qu’on devrait toujours activer pour un service applicatif. LimitNOFILE=65536 autorise un grand nombre de fichiers ouverts simultanément, important pour les serveurs HTTP/WebSocket.
La tâche correspondante :
- name: Déposer l'unité systemd
ansible.builtin.template:
src: nodejs-app.service.j2
dest: "/etc/systemd/system/{{ nodejs_app_name }}.service"
owner: root
group: root
mode: '0644'
notify:
- daemon reload
- restart nodejs app
- name: Activer et démarrer le service
ansible.builtin.systemd_service:
name: "{{ nodejs_app_name }}"
enabled: true
state: started
daemon_reload: true
Étape 7 — Le reverse proxy Nginx
Le serveur Node écoute sur un port haut (3000 par défaut), accessible uniquement depuis localhost. Nginx en frontal gère TLS, compression, cache statique, redirections. On ajoute un vhost à la configuration Nginx existante :
server {
listen 80;
server_name {{ nodejs_app_domain }};
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name {{ nodejs_app_domain }};
ssl_certificate /etc/letsencrypt/live/{{ nodejs_app_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ nodejs_app_domain }}/privkey.pem;
location / {
proxy_pass http://127.0.0.1:{{ nodejs_app_port }};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
{{ nodejs_app_nginx_extra }}
}
Les en-têtes Upgrade et Connection permettent les WebSockets si l’application en utilise. X-Forwarded-For et X-Forwarded-Proto donnent à l’application visibilité sur l’IP réelle du client et le protocole d’origine — important pour les frameworks qui génèrent des URLs absolues.
Étape 8 — Déployer la première version
On lance le playbook sur l’inventaire :
ansible-playbook -i inventory/ playbooks/nodejs.yml \
--vault-password-file ~/.ansible/vault-pass-dev \
--diff --limit role_web
La sortie défile : utilisateur créé, code cloné, dépendances installées, build exécuté, unité déposée, service démarré, vhost Nginx en place. À la fin, on vérifie depuis l’extérieur :
curl -I https://app.example.com/
L’en-tête HTTP/2 200 confirme que la chaîne complète fonctionne. Pour observer les logs en direct :
ansible -i inventory/ role_web -m ansible.builtin.command \
-a 'journalctl -u monapp -n 50 --no-pager' --become
Étape 9 — Déploiements suivants sans coupure
Pour les déploiements suivants on veut éviter de couper le service pendant le build. Deux patterns coexistent.
Le pattern symlinks, popularisé par Capistrano, déploie chaque release dans un répertoire daté et bascule le symlink current à la fin :
/opt/monapp/
├── releases/
│ ├── 20260105-141200/
│ ├── 20260105-153400/
│ └── 20260106-091800/
├── current -> releases/20260106-091800
└── shared/
└── .env
L’unité systemd pointe sur {{ nodejs_app_root }}/current/, qui résout vers la dernière release. Un déploiement clone une nouvelle release, installe et build, puis bascule le symlink et notifie le restart. Le rollback consiste à remettre le symlink sur la release précédente.
Le pattern multiple instances avec serial: 1 au niveau du play : Ansible déploie une cible à la fois, le load balancer en frontal redirige le trafic vers les instances saines pendant que la cible courante redémarre. C’est la méthode privilégiée pour les flottes plus larges.
Pour ce premier tour, le pattern symlinks suffit. Le rôle peut implémenter les deux progressivement.
Étape 10 — Sauvegardes applicatives et stratégie de rétention
Une application Node.js stocke rarement ses propres données — base et fichiers utilisateurs vivent ailleurs. Mais les artefacts intermédiaires (versions packagées, fichiers de configuration générés, archives de builds) méritent une stratégie. Le rôle peut intégrer une tâche de nettoyage des anciennes releases pour ne pas saturer le disque :
- name: Lister les releases existantes
ansible.builtin.find:
paths: "{{ nodejs_app_root }}/releases"
file_type: directory
age_stamp: ctime
register: existing_releases
- name: Conserver uniquement les 5 dernières releases
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ (existing_releases.files | sort(attribute='ctime', reverse=true))[5:] }}"
loop_control:
label: "{{ item.path }}"
Ce pattern garde les cinq releases les plus récentes — assez pour rollbacker rapidement plusieurs étages, pas assez pour saturer un disque de 50 Go. La stratégie est conservatrice par défaut ; on peut surcharger via une variable nodejs_app_releases_to_keep exposée dans defaults/main.yml.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| Service démarre puis s’arrête immédiatement | Variable d’environnement obligatoire absente | Vérifier journalctl -u monapp -n 50 ; surcharger nodejs_app_extra_env |
EADDRINUSE au démarrage |
Une autre instance occupe déjà le port | ss -tlnp | grep :3000 sur la cible — tuer ou changer de port |
npm ci échoue avec Permission denied |
become_user incorrect, le user n’est pas owner |
Vérifier les ownerships du répertoire current/ avant l’install |
Logs ne remontent pas dans journalctl |
SyslogIdentifier manquant |
Ajouter SyslogIdentifier=monapp dans [Service] |
| WebSockets coupés au bout de 60 secondes | proxy_read_timeout trop court côté Nginx |
Passer à 3600s ou plus pour les WS longs |
Le déploiement écrase le .env |
.env versionné dans le code au lieu d’être dans shared/ |
Sortir le .env du dépôt, le monter via le pattern symlink |
Tutoriels frères
Pour explorer plus loin
- Module
systemdofficiel - Directives systemd
[Service]— référence des options de durcissement - Calendrier des LTS Node.js
- 🔝 Retour à l’article principal du dossier Ansible
FAQ
Pourquoi npm ci et pas npm install ?
npm ci respecte strictement le package-lock.json et refuse d’installer si le lockfile est désynchronisé. C’est exactement ce qu’on veut en production : une garantie de reproductibilité. npm install peut modifier le lockfile silencieusement et introduire une dépendance non auditée.
Comment passer de Node 22 à 24 ?
On surcharge nodejs_version_major: "24", le rôle reconfigure le dépôt NodeSource et apt installe la nouvelle version. Le service redémarre dessus. À tester sur staging d’abord — Node 24, sorti en mai 2025, a apporté quelques cassures sur les modules natifs anciens (notamment sur les bindings Python 3.x intégrés à node-gyp).
Faut-il systemd ou Docker ?
Les deux fonctionnent. Docker offre une isolation plus forte, systemd est plus simple à observer et n’introduit pas de couche supplémentaire. Pour une PME avec peu d’applications, systemd reste plus léger ; à partir d’une dizaine de microservices, Docker (ou Kubernetes) commence à payer son coût d’apprentissage.
Comment gérer les variables d’environnement sensibles ?
Trois options. La première est de les mettre dans nodejs_app_extra_env chiffré par Vault — simple mais elles transitent par l’unité systemd visible avec systemctl cat. La deuxième est de générer un fichier EnvironmentFile= séparé en mode 600. La troisième consiste à les récupérer via un agent au démarrage (HashiCorp Vault, AWS Parameter Store).
Quel impact CPU des restrictions ProtectSystem=strict ?
Imperceptible. Ces directives s’appuient sur les namespaces du kernel, déjà très optimisés. Le seul effet pratique : si l’application essaie d’écrire en dehors des chemins autorisés, elle échoue avec Read-only file system. C’est exactement ce qu’on veut.
Comment surveiller le service ?
systemctl is-active monapp retourne active ou failed. journalctl -u monapp --since '1 hour ago' --priority=err liste les erreurs récentes. Pour la prod, on branche un endpoint /healthz dans l’application et un superviseur externe (UptimeRobot, Better Stack) qui appelle régulièrement.