Mise en production sur une machine Linux avec HTTPS Let's Encrypt

Contexte

Procédure de référence pour déployer une instance gitrust sur une machine Linux à IP fixe, avec patte externe routée et HTTPS via Let's Encrypt. Reproductible et testée en conditions réelles.

Adaptez les valeurs suivantes à votre environnement :

Placeholder Remplacez par
<your-server-ip> L'IP fixe de votre serveur (ex: 192.168.1.57 sur un LAN, ou une IP publique)
<your-domain> Votre FQDN principal (ex: gitrust.example.com)
<acme-email> L'email pour Let's Encrypt (ex: contact@example.com)
<admin-user> L'utilisateur SSH admin sur le serveur cible

Les trois services gitrust

# Service Bind interne Exposition publique Variable
1 Web HTTP (UI SSR + API) 127.0.0.1:4000 :443 HTTPS via Nginx SERVER_PORT
2 SSH Git (russh) 127.0.0.1:2222 :22 via Nginx stream SSH_PORT
3 Worker CI/CD (Dagger) tâche Tokio in-process — (déclenché par push) CI_ENABLED

PostgreSQL = dépendance, conteneur Docker bind loopback 127.0.0.1:5432.

Topologie réseau

Internet
    |
    v
<your-server-ip> (patte externe)
    |
    +-- :22   Nginx stream  --> 127.0.0.1:2222 (gitrust SSH)
    +-- :80   Nginx HTTP    --> 301 HTTPS (sauf /.well-known/acme-challenge)
    +-- :443  Nginx HTTPS   --> 127.0.0.1:4000 (gitrust HTTP)
    +-- :2022 sshd système  (accès admin, déplacé depuis :22)

    Loopback uniquement :
    +-- 127.0.0.1:4000  gitrust web
    +-- 127.0.0.1:2222  gitrust SSH
    +-- 127.0.0.1:5432  postgres docker

Risque critique — ordre d'exécution

Le déplacement de sshd système de :22 vers :2022 doit être fait avant la configuration nginx stream, et doit être validé depuis une deuxième session SSH avant fermeture de la première. Sinon : lockout total (besoin d'accès console physique).


Pré-requis

Poste de build (machine locale avec le code source)

rustup show && command -v rsync envsubst npx
# Installer si besoin : sudo apt install rsync gettext-base

Cible <your-server-ip>

  • Debian/Ubuntu récent
  • Accès SSH avec sudo
  • Docker + plugin compose (docker compose version)
  • Snap ou apt pour certbot + plugin nginx
  • DNS <your-domain> → IP publique de la patte externe (vérifier dig +short <your-domain>)
  • Module stream Nginx disponible (paquet libnginx-mod-stream sur Debian)

Phase 0 — Générer .env.production (poste de build, une seule fois)

JWT=$(openssl rand -hex 64)
ADMIN_PWD=$(openssl rand -base64 24)
PG_PWD=$(openssl rand -base64 24)

cat > .env.production <<EOF
# ---- Base de données ----
DATABASE_URL=postgres://gitrust:${PG_PWD}@127.0.0.1:5432/gitrust

# ---- Serveur HTTP (derrière Nginx HTTPS) ----
SERVER_HOST=127.0.0.1
SERVER_PORT=4000

# ---- Serveur SSH (derrière nginx stream :22) ----
SSH_PORT=2222
SSH_LISTEN_ADDR=127.0.0.1
SSH_PUBLIC_HOST=<your-domain>
SSH_HOST_KEY_PATH=/opt/gitrust/data/ssh_host_ed25519_key

# ---- Chemins (réécrits par deploy.sh) ----
GIT_REPOS_BASE_PATH=/opt/gitrust/data/repos
STATIC_FILES_PATH=/opt/gitrust/static

# ---- Sécurité — HTTPS actif ----
JWT_SECRET=${JWT}
JWT_EXPIRATION_MINUTES=15
REFRESH_TOKEN_EXPIRATION_DAYS=7
COOKIE_SECURE=true            # HTTPS via Nginx
COOKIE_SAME_SITE=Lax
APP_DEBUG=false
RUST_LOG=info

# ---- Admin initial ----
ADMIN_USERNAME=admin
ADMIN_EMAIL=<acme-email>
ADMIN_PASSWORD=${ADMIN_PWD}

# ---- Inscription & UI ----
ALLOW_REGISTRATION=false
APP_NAME=Gitrust
DEFAULT_LOCALE=fr

# ---- Email ----
EMAIL_VALIDATION_REQUIRED=false
EMAIL_BASE_URL=https://<your-domain>

# ---- CI/CD Dagger (worker 3) ----
CI_ENABLED=true
CI_MAX_CONCURRENT=4
CI_DEFAULT_TIMEOUT=3600
CI_WORKSPACE_PATH=/tmp/gitrust-ci
CI_REMOTE_HOST=localhost

# ---- Worker import ----
IMPORT_MAX_CONCURRENT=2
IMPORT_TIMEOUT_SECS=1800
DB_WORKER_POOL_SIZE=4
EOF

chmod 600 .env.production
echo "ADMIN_PASSWORD = ${ADMIN_PWD}"
echo "POSTGRES_PASSWORD = ${PG_PWD}"

Critique : noter ADMIN_PASSWORD et POSTGRES_PASSWORD dans un gestionnaire de mots de passe.


Phase 1 — Préparer la cible : déplacer sshd système sur :2022

À faire en gardant deux sessions SSH simultanées.

1.pre. Diagnostic de l'état initial

1.pre.1 — Trouver le port SSH ouvert depuis le poste de build

# Depuis votre poste (sans être connecté) :
for p in 22 2022 2222 2200 22022; do
  timeout 3 bash -c "echo >/dev/tcp/<your-server-ip>/$p" 2>/dev/null \
    && echo "Port $p : OUVERT" \
    || echo "Port $p : fermé/filtré"
done

1.pre.2 — Se connecter sur le port détecté

# Cas le plus probable : sshd est sur :22 par défaut
ssh <admin-user>@<your-server-ip>

1.pre.3 — Une fois connecté, vérifier l'état sshd actuel

# Sur quel(s) port(s) sshd écoute-t-il ?
sudo ss -tlnp | grep sshd

# Contenu de la config
sudo grep -riE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null

# Statut du service
sudo systemctl status ssh --no-pager

1a. Session 1 : modifier la config sshd

ssh <admin-user>@<your-server-ip>

# Éditer /etc/ssh/sshd_config
sudo tee /etc/ssh/sshd_config.d/99-gitrust.conf <<'EOF'
Port 2022
EOF

# Vérifier la syntaxe
sudo sshd -t

# Si OK, reload (ne déconnecte pas la session courante)
sudo systemctl reload ssh
sudo ss -tlnp | grep -E ':22\b|:2022\b'
# Attendu : sshd écoute sur 22 ET 2022

1b. Session 2 : valider le nouveau port

Depuis le poste de build, dans un autre terminal :

ssh -p 2022 <admin-user>@<your-server-ip> 'echo "OK port 2022"'
# Doit afficher : OK port 2022

1c. (Plus tard, après Phase 5) Fermer le port 22 du sshd système

Une fois nginx stream :22 actif et testé :

ssh -p 2022 <admin-user>@<your-server-ip>
sudo sed -i 's/^Port 22$/# Port 22 (déplacé vers 2022, :22 utilisé par nginx stream)/' /etc/ssh/sshd_config
sudo systemctl restart ssh

1d. Firewall

ssh -p 2022 <admin-user>@<your-server-ip> bash <<'EOF'
sudo ufw allow 80/tcp comment 'HTTP ACME + redirect'
sudo ufw allow 443/tcp comment 'HTTPS Gitrust'
sudo ufw allow 22/tcp comment 'SSH Git via nginx stream'
sudo ufw allow 2022/tcp comment 'SSH admin système'
sudo ufw status numbered
EOF

Mettre à jour ~/.ssh/config côté poste dev :

Host gitrust-host
    HostName <your-server-ip>
    User <admin-user>
    Port 2022
Le reste du plan utilise ssh gitrust-host.


Phase 2 — PostgreSQL (Docker, loopback)

scp -P 2022 database/docker-compose.yml <admin-user>@<your-server-ip>:/tmp/pg-compose.yml

ssh gitrust-host bash <<EOF
sudo mkdir -p /opt/gitrust/database
sudo mv /tmp/pg-compose.yml /opt/gitrust/database/docker-compose.yml
echo 'POSTGRES_PASSWORD=${PG_PWD}' | sudo tee /opt/gitrust/database/.env >/dev/null
sudo chmod 600 /opt/gitrust/database/.env
cd /opt/gitrust/database && sudo docker compose up -d
sudo docker compose ps
EOF

Remplacer ${PG_PWD} par la valeur réelle. Vérifier que le port n'est pas exposé publiquement :

ssh gitrust-host 'sudo ss -tlnp | grep 5432'
# Attendu : 127.0.0.1:5432 — PAS 0.0.0.0

Phase 3 — Build + déploiement gitrust via deploy.sh

3a. Préparer deployment/deploy.conf

cp deployment/deploy.conf.example deployment/deploy.conf
# Éditer DEPLOY_TARGET=gitrust-host, DEPLOY_REMOTE_PATH=/opt/gitrust, DEPLOY_FQDN_INITIAL=<your-domain>
$EDITOR deployment/deploy.conf
chmod 600 deployment/deploy.conf

3b. Validation du build seul (optionnel)

BUILD_ONLY=1 ./deployment/deploy.sh

3c. Déploiement

# Sans argument : utilise DEPLOY_TARGET du deploy.conf (gitrust-host via ~/.ssh/config, port 2022)
./deployment/deploy.sh

Ce que fait deploy.sh : cargo build --release, tailwindcss --minify, adapte gitrust.service + .env, rsync, crée user gitrust, installe systemd, restart.

À la fin : gitrust écoute sur 127.0.0.1:4000 (HTTP) et 127.0.0.1:2222 (SSH).

ssh gitrust-host 'sudo systemctl status gitrust --no-pager && sudo ss -tlnp | grep -E "4000|2222"'
# Attendu : 127.0.0.1:4000 et 127.0.0.1:2222 (PAS 0.0.0.0)

Phase 4 — Nginx HTTP + Let's Encrypt

4a. Installer nginx + certbot

ssh gitrust-host bash <<'EOF'
sudo apt update
sudo apt install -y nginx libnginx-mod-stream certbot python3-certbot-nginx
sudo systemctl enable --now nginx
EOF

4b. Déployer la conf Nginx

scp -P 2022 deployment/nginx-gitrust.conf <admin-user>@<your-server-ip>:/tmp/gitrust-nginx.conf

ssh gitrust-host bash <<EOF
sudo sed -i 's/gitrust.nuage.ebii/<your-domain>/g' /tmp/gitrust-nginx.conf
sudo mv /tmp/gitrust-nginx.conf /etc/nginx/sites-available/gitrust
sudo ln -sf /etc/nginx/sites-available/gitrust /etc/nginx/sites-enabled/gitrust
sudo rm -f /etc/nginx/sites-enabled/default
sudo mkdir -p /var/www/html/.well-known/acme-challenge
sudo nginx -t && sudo systemctl reload nginx
EOF

4c. Émettre le certificat Let's Encrypt

ssh gitrust-host 'sudo certbot --nginx \
    -d <your-domain> \
    --email <acme-email> \
    --agree-tos \
    --no-eff-email \
    --redirect'

4d. Vérifier le renouvellement automatique

ssh gitrust-host 'sudo systemctl list-timers | grep certbot'
ssh gitrust-host 'sudo certbot renew --dry-run'

4e. Test HTTPS

curl -I https://<your-domain>/
# Attendu : HTTP/2 200 (ou 302 vers /login)

# Vérifier HSTS
curl -sI https://<your-domain>/ | grep -i strict-transport

Phase 5 — Exposer SSH Git sur :22 via Nginx stream

5a. Ajouter le bloc stream

ssh gitrust-host bash <<'EOF'
sudo nginx -V 2>&1 | grep -o with-stream

sudo tee -a /etc/nginx/nginx.conf <<'NGINX'

# --- Gitrust SSH (proxy :22 -> :2222) -----------------------------------------
stream {
    upstream gitrust_ssh {
        server 127.0.0.1:2222;
    }
    server {
        listen 22;
        listen [::]:22;
        proxy_pass gitrust_ssh;
        proxy_timeout 1h;
        proxy_connect_timeout 30s;
        error_log /var/log/nginx/gitrust-ssh.log;
    }
}
NGINX

sudo nginx -t && sudo systemctl restart nginx
sudo ss -tlnp | grep -E ':22\b|:2222\b'
# Attendu : nginx :22, gitrust :2222 (loopback)
EOF

5b. Désactiver sshd système sur :22 (Phase 1c)

ssh -p 2022 <admin-user>@<your-server-ip> bash <<'EOF'
sudo sed -i 's/^Port 22$/# Port 22 déplacé/' /etc/ssh/sshd_config 2>/dev/null || true
sudo systemctl restart ssh
sudo ss -tlnp | grep -E ':22\b|:2022\b'
# Attendu : nginx :22, sshd :2022 — plus de sshd:22
EOF

5c. Test clone Git

# Dans l'UI gitrust : Settings -> SSH keys -> coller ~/.ssh/id_ed25519.pub
# Créer un repo test admin/test-deploy via UI

git clone git@<your-domain>:admin/test-deploy.git /tmp/test-deploy
cd /tmp/test-deploy
echo "# test" > README.md
git -c user.email=<acme-email> -c user.name=admin add . && \
  git -c user.email=<acme-email> -c user.name=admin commit -m "test deploy" && \
  git push origin main

Phase 6 — Vérifications globales

# Service systemd
ssh gitrust-host 'sudo systemctl status gitrust --no-pager'

# Logs (recherche d'erreurs)
ssh gitrust-host 'sudo journalctl -u gitrust -n 100 --no-pager | grep -iE "error|warn|fail"'

# Ports d'écoute (vue d'ensemble)
ssh gitrust-host 'sudo ss -tlnp'
# Attendu :
#   :22    nginx
#   :80    nginx
#   :443   nginx
#   :2022  sshd système
#   127.0.0.1:2222  gitrust
#   127.0.0.1:4000  gitrust
#   127.0.0.1:5432  docker-proxy

# Renouvellement TLS automatique
ssh gitrust-host 'sudo systemctl status certbot.timer --no-pager'

# Test E2E
curl -I https://<your-domain>/login
ssh -T git@<your-domain> || true   # Banner SSH russh attendu

Phase 7 — Durcissement : Fail2ban

Voir admin/how-to/durcir-avec-fail2ban.md pour la configuration complète.

7a. Installation

ssh gitrust-host bash <<'EOF'
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
EOF

7b. Configuration minimale rapide

ssh gitrust-host sudo tee /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
backend  = systemd
bantime  = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1
banaction          = ufw
banaction_allports = ufw

[sshd]
enabled  = true
port     = 2022
filter   = sshd
backend  = %(sshd_backend)s
logpath  = %(sshd_log)s
maxretry = 3

[nginx-http-auth]
enabled  = true
port     = http,https
filter   = nginx-http-auth
logpath  = /var/log/nginx/gitrust.error.log

[gitrust-login]
enabled  = true
port     = http,https
filter   = gitrust-login
logpath  = /var/log/nginx/gitrust.access.log
maxretry = 5

[gitrust-ssh]
enabled      = true
port         = 22,2222
filter       = gitrust-ssh
backend      = systemd
journalmatch = _SYSTEMD_UNIT=gitrust.service
maxretry     = 5

[recidive]
enabled   = true
logpath   = /var/log/fail2ban.log
banaction = %(banaction_allports)s
bantime   = 1w
findtime  = 1d
maxretry  = 3
EOF

Créer les filtres gitrust-login et gitrust-ssh, puis :

ssh gitrust-host 'sudo systemctl restart fail2ban && sudo fail2ban-client status'

Phase 8 — Mise à jour vers un nouveau domaine (quand DNS prêt)

8a. Ajouter le SAN au certificat existant

ssh gitrust-host 'sudo certbot --nginx --expand \
    -d <your-domain> \
    -d <new-domain> \
    --email <acme-email> \
    --agree-tos --no-eff-email'

8b. Mettre à jour la conf Nginx

ssh gitrust-host bash <<EOF
sudo sed -i 's/server_name <your-domain>;/server_name <your-domain> <new-domain>;/g' /etc/nginx/sites-available/gitrust
sudo nginx -t && sudo systemctl reload nginx
EOF

8c. Mettre à jour .env.production et redéployer

sed -i 's|SSH_PUBLIC_HOST=.*|SSH_PUBLIC_HOST=<new-domain>|' .env.production
sed -i 's|EMAIL_BASE_URL=.*|EMAIL_BASE_URL=https://<new-domain>|' .env.production
./deployment/deploy.sh gitrust-host

Phase 9 — Mises à jour ultérieures

git pull
SKIP_ENV=1 SKIP_DB=1 ./deployment/deploy.sh

Dépannage

Symptôme Diagnostic Fix
Lockout SSH après reload sshd Console physique / KVM IPMI
Cert Let's Encrypt échoue (Connection refused) dig +short <your-domain> ≠ IP publique Corriger DNS, ou vérifier que :80 traverse bien le NAT
Cert échoue (unauthorized) Nginx :80 ne répond pas sur /.well-known/acme-challenge/ curl http://<your-domain>/.well-known/acme-challenge/test doit ne pas rediriger en 301
Login boucle sur /login malgré HTTPS grep COOKIE_SECURE /opt/gitrust/.env Doit être true ; vérifier que Nginx forward bien X-Forwarded-Proto https
Push SSH Connection closed journalctl -u gitrust \| grep ssh Vérifier que gitrust écoute bien 127.0.0.1:2222 et que nginx stream forward
nginx -t : unknown directive "stream" Module non chargé sudo apt install libnginx-mod-stream puis systemctl restart nginx
Mixed content HTTPS Templates qui hardcodent http:// Vérifier EMAIL_BASE_URL=https://... et headers X-Forwarded-Proto
Push gros repo timeout Logs nginx client intended to send too large body Augmenter client_max_body_size 2G (déjà fait pour .git/)

Récapitulatif des ports

Port Process Exposition Rôle
22 nginx (stream) Public SSH Git → forward vers :2222
80 nginx Public ACME challenge + redirect HTTPS
443 nginx Public HTTPS → forward vers :4000
2022 sshd système Public Admin SSH (à restreindre par firewall si possible)
2222 gitrust Loopback Backend SSH russh
4000 gitrust Loopback Backend HTTP axum
5432 docker-proxy Loopback PostgreSQL