← Blogue

Déployer une application web Go sur Hetzner

Railyard

22 août 2026 · 14 min de lecture

Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte

Un binaire Go est étonnamment facile à mettre sur un serveur : pas d’interpréteur à installer, pas de gestionnaire de paquets à exécuter, aucune version de runtime à fixer sur l’hôte. On le compile une fois, on le copie, et ça tourne. Cette simplicité n’élimine pas le reste du travail pour autant. Il faut quand même un serveur, un domaine qui pointe dessus, une base de données, le HTTPS devant tout ça, une sauvegarde qui survit à la disparition du serveur, et une façon courte de livrer le prochain changement. Ce guide construit tout ça à la main, puis montre le raccourci.

L’application elle-même est volontairement petite : un service net/http utilisant uniquement la bibliothèque standard, avec une route qui lit et écrit dans Postgres via pgx/v5, histoire d’avoir une vraie raison de parler de connexion à une base de données et pas juste un « hello world » statique.

Ce qu’il vous faut

  • Un compte Hetzner Cloud et un serveur. Le CX23 économique (2 vCPU, 4 Go de mémoire, 40 Go NVMe) est affiché à 5,99 € par mois hors TVA. Vérifiez les prix et la disponibilité actuels dans la console Hetzner Cloud avant de commander, puisque les gammes et les prix changent avec le temps.
  • Un nom de domaine auquel vous pouvez ajouter un enregistrement DNS.
  • Une paire de clés SSH sur votre machine (ssh-keygen -t ed25519 si vous n’en avez pas déjà une).
  • Go 1.27 ou plus récent installé localement, pour écrire et construire l’application avant même qu’elle touche le serveur.
  • Environ quinze minutes pour que le serveur et le DNS se stabilisent, surtout du temps d’attente plutôt que de la frappe au clavier.

Étape 1 : créer le serveur et pointer un domaine dessus

Dans la console Hetzner Cloud, ouvrez (ou créez) un projet, puis choisissez Add Server. Choisissez un emplacement : Nuremberg, Falkenstein ou Helsinki gardent vos données dans l’UE, tandis qu’Ashburn, Hillsboro ou Singapour sont aussi proposés si vos utilisateurs sont ailleurs. Choisissez Ubuntu 24.04 comme image, puis sous type de serveur choisissez le vCPU partagé CX23 (2 vCPU, 4 Go de mémoire, 40 Go NVMe). Sous clés SSH, ajoutez la clé publique de votre machine plutôt que de vous fier à un mot de passe généré, puis créez le serveur.

Une fois démarré, la console affiche son adresse IPv4 publique. Connectez-vous pour confirmer l’accès :

ssh root@YOUR_SERVER_IP

Vous devriez voir la bannière de bienvenue d’Ubuntu et arriver sur un shell root. Si la connexion est refusée, vérifiez que la clé SSH attachée au serveur correspond bien à la clé privée sur votre machine.

Ajoutez un enregistrement A chez votre fournisseur DNS pour le nom d’hôte voulu, pointant vers cette IP :

Type: A
Name: myapp (ou @ pour le domaine nu)
Value: YOUR_SERVER_IP
TTL: 300

Vérifiez que ça résout avant de continuer, puisque Caddy en aura besoin plus loin :

dig +short myapp.example.com

Vous devriez voir l’IP de votre serveur s’afficher. Si vous n’obtenez rien, attendez quelques minutes et réessayez.

Hetzner ne met pas de pare-feu devant un nouveau serveur par défaut, alors verrouillez l’hôte sur strictement ce dont vous avez besoin avec ufw :

ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable

Étape 2 : installer Docker sur le serveur

Le script d’installation de Docker s’occupe à la fois du moteur et du plugin Compose sur un Ubuntu fraîchement installé :

apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker

Vérifiez l’installation :

docker --version
docker compose version

Vous devriez voir une version de Docker et une version de Compose s’afficher, pas une erreur « command not found ».

Étape 3 : écrire l’application Go

Sur votre propre machine, configurez le module :

mkdir myapp && cd myapp
go mod init myapp
go get github.com/jackc/pgx/v5

go mod init et go get écrivent un go.mod qui ressemble à ceci (vos versions exactes de dépendances peuvent légèrement varier selon le moment où vous exécutez la commande, puisque go mod tidy les résout au moment de la requête) :

module myapp

go 1.27

require github.com/jackc/pgx/v5 v5.7.6

require (
	github.com/jackc/pgpassfile v1.0.0 // indirect
	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
	github.com/jackc/puddle/v2 v2.2.2 // indirect
	golang.org/x/crypto v0.35.0 // indirect
	golang.org/x/sync v0.11.0 // indirect
	golang.org/x/text v0.22.0 // indirect
)

Maintenant main.go, qui n’utilise que net/http de la bibliothèque standard, plus pgxpool pour la base de données :

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

var pool *pgxpool.Pool

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	dbURL := os.Getenv("DATABASE_URL")
	if dbURL == "" {
		log.Fatal("DATABASE_URL is not set")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	var err error
	pool, err = pgxpool.New(ctx, dbURL)
	if err != nil {
		log.Fatalf("unable to create connection pool: %v", err)
	}
	defer pool.Close()

	if err := pool.Ping(ctx); err != nil {
		log.Fatalf("unable to reach postgres: %v", err)
	}

	if _, err := pool.Exec(ctx, `
		CREATE TABLE IF NOT EXISTS visits (
			id SERIAL PRIMARY KEY,
			created_at TIMESTAMPTZ NOT NULL DEFAULT now()
		)
	`); err != nil {
		log.Fatalf("unable to create visits table: %v", err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", handleHealthz)
	mux.HandleFunc("/visits", handleVisits)

	addr := "0.0.0.0:" + port
	log.Printf("listening on %s", addr)
	if err := http.ListenAndServe(addr, mux); err != nil {
		log.Fatal(err)
	}
}

func handleHealthz(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("ok"))
}

type visitsResponse struct {
	Count int64 `json:"count"`
}

func handleVisits(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	if _, err := pool.Exec(ctx, "INSERT INTO visits DEFAULT VALUES"); err != nil {
		http.Error(w, "database error", http.StatusInternalServerError)
		return
	}

	var count int64
	if err := pool.QueryRow(ctx, "SELECT count(*) FROM visits").Scan(&count); err != nil {
		http.Error(w, "database error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(visitsResponse{Count: count})
}

Deux choses comptent ici au-delà des handlers eux-mêmes. D’abord, l’application lit PORT depuis l’environnement et retombe sur 8080 par défaut, et elle se lie à 0.0.0.0:$PORT, pas à localhost:$PORT. Caddy tourne dans son propre conteneur et joint cette application via le réseau Docker par le nom du conteneur, pas par l’interface de loopback, donc se lier à localhost ferait en sorte que Caddy ne puisse jamais l’atteindre. Ensuite, DATABASE_URL est lu depuis l’environnement plutôt que codé en dur, de sorte que le même binaire exact tourne sans changement contre une base locale, une base de staging ou celle de production sur ce serveur.

Ça vaut la peine de nommer ce qui manque par rapport à un langage interprété. Un déploiement Rails ou Django doit s’assurer que la bonne version de Ruby ou Python est installée sur le serveur, et que les gems ou paquets correspondants sont récupérés et trouvés aux chemins attendus par l’interpréteur. Rien de tout ça ici. go build produit un seul binaire avec tout déjà lié dedans ; le serveur n’a pas besoin de Go installé du tout, juste le fichier compilé. C’est aussi pourquoi le Dockerfile de l’étape suivante a deux étapes : une pour compiler, une pour simplement exécuter le résultat.

Étape 4 : écrire le Dockerfile

FROM golang:1.27.3-alpine AS build

WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server .

FROM alpine:3.21

RUN apk add --no-cache ca-certificates

COPY --from=build /out/server /usr/local/bin/server

EXPOSE 8080

CMD ["server"]

La première étape utilise golang:1.27.3-alpine pour compiler le binaire. CGO_ENABLED=0 désactive cgo, ce qui fait que le binaire résultant ne se lie pas du tout à la bibliothèque C du système, et GOOS=linux garantit un binaire Linux même si vous construisez sur un Mac. La deuxième étape part d’un simple alpine:3.21, bien plus petit que l’image de build, et ne copie que le binaire server compilé. ca-certificates est installé pour que le binaire puisse vérifier les certificats TLS si DATABASE_URL pointe un jour vers une instance Postgres exigeant SSL ; pgx n’a aucune dépendance C une fois CGO_ENABLED=0 fixé, donc rien d’autre n’est nécessaire dans cette étape d’exécution.

Étape 5 : écrire le fichier Docker Compose

Copiez votre application sur le serveur (scp, rsync, ou un git clone de votre dépôt dans /opt/myapp), créez un fichier .env avec un mot de passe de base de données, puis ajoutez compose.yml à côté :

POSTGRES_PASSWORD=a-long-random-password
services:
  app:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://myapp:${POSTGRES_PASSWORD}@db:5432/myapp?sslmode=disable
      PORT: "8080"
    depends_on:
      db:
        condition: service_healthy
    expose:
      - "8080"

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: myapp
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 10

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app

volumes:
  db_data:
  caddy_data:
  caddy_config:

Le service app joint Postgres via le nom d’hôte db, le nom que Compose donne à ce service, pas localhost. depends_on: condition: service_healthy empêche l’application de démarrer avant que Postgres puisse réellement accepter des connexions, pas juste avant que son conteneur existe. Seul caddy publie des ports vers l’hôte, donc Postgres et l’application elle-même ne sont jamais accessibles depuis l’extérieur de ce serveur.

Vérifiez que le fichier s’analyse correctement avant de continuer :

docker compose config

Vous devriez voir la configuration entièrement résolue s’afficher sans ligne d’erreur.

Étape 6 : écrire le Caddyfile

myapp.example.com {
	reverse_proxy app:8080
}

Remplacez myapp.example.com par le domaine que vous avez pointé vers le serveur à l’étape 1, et 8080 par le PORT que vous avez fixé dans compose.yml si vous le changez. Caddy fait suivre chaque requête vers le conteneur app sur ce port, et demande un certificat TLS gratuit à Let’s Encrypt pour ce nom d’hôte exact, en le renouvelant automatiquement. Ça ne fonctionne que si le domaine résout déjà vers ce serveur, d’où le fait que le DNS soit venu avant Caddy dans ce guide. Vous pouvez vérifier que le Caddyfile est bien formé avec :

docker run --rm -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile

Vous devriez voir Valid configuration dans la sortie.

Étape 7 : démarrer la pile

Depuis /opt/myapp :

docker compose up -d --build

Ceci construit l’image, démarre Postgres, attend qu’il se déclare en santé, puis démarre l’application, qui se connecte à Postgres et crée sa seule table au premier démarrage.

Vérifiez la pile :

docker compose ps
curl -I https://myapp.example.com/healthz

Vous devriez voir les trois services comme Up (et db comme healthy), et le curl devrait retourner HTTP/2 200 sans avertissement de certificat. Frapper /visits plusieurs fois devrait retourner un compte croissant :

curl https://myapp.example.com/visits

Étape 8 : sauvegarder Postgres

Un dump pris en dehors du volume de données est ce qui vous protège réellement si le serveur disparaît. Ajoutez un script de sauvegarde :

cat > /opt/myapp/backup.sh <<'EOF'
#!/bin/sh
cd /opt/myapp
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U myapp myapp | gzip > /opt/myapp/backups/$TIMESTAMP.sql.gz
find /opt/myapp/backups -name '*.sql.gz' -mtime +14 -delete
EOF
mkdir -p /opt/myapp/backups
chmod +x /opt/myapp/backup.sh

Planifiez-le avec cron, chaque jour à 3h du matin :

(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -

Exécutez-le une fois à la main et vérifiez que ça a fonctionné :

/opt/myapp/backup.sh
ls -lh /opt/myapp/backups

Vous devriez voir un fichier .sql.gz avec l’horodatage du jour et une taille non nulle. Pour tout ce qui dépasse un projet personnel, copiez aussi ces fichiers hors du serveur, vers une autre machine ou un stockage objet, pour que perdre le serveur ne signifie pas aussi perdre la sauvegarde.

Étape 9 : livrer une mise à jour

Chaque changement ultérieur suit la même forme : amener le nouveau code sur le serveur, reconstruire l’image, remplacer le conteneur en cours d’exécution.

git pull
docker compose build app
docker compose up -d app

docker compose up -d app ne recrée que le conteneur app, pas db ni caddy, donc Postgres continue de tourner et l’état du certificat de Caddy n’est pas touché. Il y a un court écart, une seconde ou deux, pendant que l’ancien conteneur s’arrête et que le nouveau démarre, puisque cette configuration fait tourner un seul conteneur d’application.

Ça vaut la peine d’être explicite sur un point que Rails et Django gèrent automatiquement et que Go ne gère pas : il n’y a pas de convention dominante unique ici pour exécuter les migrations de schéma dans le cadre d’un déploiement. L’unique table de ce guide est créée avec CREATE TABLE IF NOT EXISTS directement dans main.go, ce qui convient pour quelque chose d’aussi petit, mais une vraie application avec un schéma qui évolue se tourne habituellement vers un outil dédié. goose et golang-migrate sont les deux plus courants, exécutés comme une étape explicite, soit intégrée à la commande de démarrage du conteneur, soit lancée à la main avant docker compose up -d app. Choisissez-en un et restez-y cohérent, puisque rien ne le fera implicitement pour vous.

Vérifiez que ça a bien pris :

docker compose logs --tail=50 app
curl -I https://myapp.example.com/healthz

Vous devriez voir votre application démarrer proprement dans les logs et le curl retourner encore 200 sur la nouvelle version.

Le raccourci : le faire avec Railyard

Tout ce qui précède (le serveur, Docker, Postgres, Caddy, les sauvegardes, une mise en production répétable) est aussi ce que Railyard automatise pour une application Go sur Hetzner, sans écrire aucun des fichiers ci-dessus à la main.

Railyard détecte Go à partir de go.mod, y compris un module qui vit dans un sous-dossier plutôt qu’à la racine du dépôt, et le construit depuis le bon endroit automatiquement. Un import de pilote de base de données reconnu dans votre code, github.com/jackc/pgx, github.com/lib/pq, github.com/go-sql-driver/mysql ou github.com/redis/go-redis, provisionne la base de données gérée correspondante pour vous et injecte DATABASE_URL ou REDIS_URL comme variable d’environnement, sans aucun addon à ajouter à la main, la même connexion que ce guide a câblée manuellement à l’étape 5. L’unique exigence reste la même que dans le Dockerfile manuel ci-dessus : le binaire doit écouter sur $PORT, sur toutes les interfaces, pas seulement localhost.

Go n’a pas de framework de file de tâches dominant comme Rails a Sidekiq ou Solid Queue, donc il n’y a rien non plus que Railyard puisse auto-détecter de ce côté. À la place, un processus worker est une seule ligne dans une déclaration de processus façon Procfile :

web:    ./server
worker: ./server -mode=worker

Les deux lignes exécutent le même binaire construit ; votre propre main() lit un flag ou une variable d’environnement pour décider quelle boucle lancer. Chaque type de processus se met à l’échelle indépendamment des autres.

Collez l’URL de votre dépôt dans https://app.railyard.run/deploy?repo= et choisissez Hetzner comme fournisseur, ou passez directement par le chemin de déploiement Go sur Hetzner. Consultez la documentation Go pour les règles de détection exactes. Le forfait gratuit de Railyard couvre un serveur plus un serveur de base de données et trois applications sans carte requise, Pro est à 19 $ par mois par équipe, et Team est à 49 $ par mois par équipe.

Dépannage

Le binaire construit localement ne tourne pas sur le serveur. Une erreur comme exec format error au démarrage du conteneur signifie habituellement que le binaire a été compilé pour la mauvaise architecture, par exemple construit sur un Mac Apple Silicon sans GOARCH=amd64 puis copié directement sur un serveur x86, ou que GOOS=linux a été oublié, de sorte que le binaire ne tourne que sur la machine qui l’a construit. Construire à l’intérieur du Dockerfile avec CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build, comme montré à l’étape 4, plutôt que de copier un binaire construit localement, évite ça puisque la construction cible toujours la bonne plateforme.

Connexion Postgres refusée. Si les logs de l’application montrent could not connect to server: Connection refused ou similaire, vérifiez que DATABASE_URL utilise db comme nom d’hôte, pas localhost ni 127.0.0.1. Dans le réseau de Compose, db est la seule adresse qui résout vers le conteneur Postgres ; localhost désigne le conteneur app lui-même, qui n’a aucune base de données en cours d’exécution dedans.

Port déjà utilisé. Si docker compose up échoue parce que quelque chose se lie déjà au port 80 ou 443, l’Apache ou le Nginx par défaut d’Ubuntu est peut-être déjà installé et en cours d’exécution. Vérifiez avec ss -tlnp | grep -E ':80|:443' et arrêtez ou retirez le service qui occupe le port avant que Caddy n’essaie de s’y lier.

Le HTTPS ne s’émet pas. Si docker compose logs caddy montre des erreurs de certificat répétées ou NXDOMAIN, votre enregistrement DNS de l’étape 1 n’a pas encore propagé ou pointe vers la mauvaise IP. Relancez dig +short myapp.example.com, confirmez que ça correspond au serveur, puis redémarrez Caddy avec docker compose restart caddy une fois que c’est bon.

Manque de mémoire sous charge. Les 4 Go de mémoire d’un CX23 font tourner cette pile confortablement au repos, mais une fuite de goroutines, par exemple en lancer une par requête sans jamais la laisser se terminer, peut faire grimper l’usage mémoire jusqu’à ce que le noyau commence à tuer des processus. docker stats sur le serveur montre si la mémoire du conteneur app grimpe dans le temps plutôt que de se stabiliser après une pointe de trafic ; le profil mémoire de pprof est l’étape suivante si c’est le cas.

FAQ

Faut-il un framework comme gin ou echo pour une petite application ? Non. net/http et http.ServeMux de la bibliothèque standard gèrent le routage, et les patterns http.Handler et middleware sont du Go pur, sans framework requis, pour tout ce qui ne compte pas des dizaines de routes ou ne veut pas de validation et de regroupement de requêtes intégrés.

Comment ajouter un processus worker ? Ajoutez un deuxième service à compose.yml, construit à partir de la même image, exécutant le même binaire avec un flag ou une commande différente, par exemple ./server -mode=worker au lieu de ./server. Il lit le même DATABASE_URL, simplement sans se lier à un port.

L’approche du binaire statique fait-elle vraiment une différence pour la sécurité ? Elle élimine toute une catégorie de problème : pas de version d’interpréteur avec ses propres CVE à corriger sur le serveur, et pas de gestionnaire de paquets qui résout des dépendances transitives au moment du déploiement. Les vulnérabilités dans vos propres dépendances Go demandent quand même un suivi, comme pour n’importe quel langage, mais la surface d’attaque de « ce qui est installé en dehors de mon application » est proche de zéro.

Et si l’application n’a aucune base de données ? Retirez complètement le service db de compose.yml, retirez DATABASE_URL de l’environnement de app, et le reste du guide ne change pas : construisez le binaire de la même façon, exécutez-le derrière Caddy de la même façon, ne sauvegardez rien puisqu’il n’y a rien d’état à sauvegarder.

Comment fonctionnent les migrations, puisque Go n’a pas de convention unique ? Contrairement au db:prepare de Rails ou au migrate de Django, rien ne s’exécute implicitement. Choisissez un outil, goose et golang-migrate sont les deux plus utilisés, et exécutez-le comme une étape explicite dans votre processus de déploiement, soit comme commande dans le point d’entrée du conteneur, soit comme étape manuelle avant docker compose up -d app. Pour un schéma aussi simple, CREATE TABLE IF NOT EXISTS au démarrage, comme le fait le main.go de ce guide, est un pis-aller raisonnable, mais ça ne gère pas la modification d’une table existante, ce qui est là où un vrai outil de migration trouve sa place.

C’est une configuration de production complète pour un service Go : un serveur, Docker Compose faisant tourner l’application et Postgres, Caddy gérant le HTTPS, une sauvegarde nocturne, et une mise en production en deux commandes. Gardez compose.yml, Caddyfile et le Dockerfile dans votre dépôt à côté de l’application, et chaque futur déploiement est le même git pull, construction, redémarrage que vous venez d’exécuter à l’étape 9.

Pourquoi les gens choisissent Railyard pour ça

  • Votre propre compte infonuagique. Déployez sur DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou n'importe quel serveur Linux par SSH, facturé directement par votre fournisseur, sans majoration.
  • Un prix fixe par équipe. L'offre gratuite est gratuite pour toujours : un serveur plus un serveur de base de données, trois applications, sans carte. Pro coûte 19 $ par mois pour jusqu'à cinq serveurs et un nombre illimité d'applications. Team coûte 49 $ par mois avec des serveurs illimités. Aucuns frais par application, par déploiement, par siège ou par gigaoctet.
  • Des gabarits en un clic. Déployez des applications open source, comme des bases de données, des outils d'analyse et des outils de développement, sur votre propre serveur en un clic.
  • Des bases de données gérées. Postgres, MySQL et Redis, avec des sauvegardes planifiées, vérifiées par restauration, et la récupération à un point dans le temps.
  • Déploiement par push. Déployez depuis GitHub, GitLab ou la CLI, avec HTTPS automatique et des mises en production sans interruption.

Sautez la configuration du serveur

Collez votre dépôt Go dans Railyard et obtenez une base de données gérée, le HTTPS et des sauvegardes configurés sur Hetzner automatiquement.

Offre gratuite, sans carte, et 14 jours de Pro pour chaque nouvelle équipe. Prix fixe par équipe.