À qui s’adresse cette annexe #
QFieldCloud auto-hébergé [E05] : l'instance de base, prête pour la production
Vous n’avez besoin de la lire que dans deux cas : vous placez une instance QFieldCloud derrière un frontal, ou vous vous demandez pourquoi vos journaux nginx ne montrent plus qu’une seule adresse IP.
Les fichiers qu’on touche #
Deux, et aucun sous src/. Chaque bloc de code plus bas rappelle en première ligne le fichier auquel il appartient — y compris les citations du sous-module, qui sont là pour être lues, pas modifiées.
| Fichier | Ce qu’on y met |
|---|---|
docker-compose.override.yml |
le profil never sur certbot |
.env |
les trois LETSENCRYPT_*, vidées et non supprimées |
Ce que présente réellement l’instance #
Commençons par regarder, plutôt que par supposer :
$ openssl s_client -connect qfieldcloud.webgis.allfabox.fr:443 \
-servername qfieldcloud.webgis.allfabox.fr </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates
issuer=C=US, O=Let's Encrypt, CN=YR1
subject=CN=*.webgis.allfabox.fr
notBefore=Sep 2 13:55:21 2026 GMT
notAfter=Dec 1 13:55:20 2026 GMT
Un vrai certificat Let’s Encrypt, jamais émis par la stack : c’est un wildcard obtenu par le frontal pour l’ensemble du domaine. Pendant ce temps, le certbot embarqué tournait depuis dix-sept heures et avait produit ceci :
$ docker run --rm -v ./src/conf/certbot/conf:/c:ro alpine ls -R /c
/c:
renewal-hooks
/c/renewal-hooks:
deploy
post
pre
Trois répertoires vides. Ce n’est pas un défaut upstream : le service fait ce pour quoi il est prévu, dans un déploiement où l’instance est en frontal direct. Il est hors-sujet ici, et une boucle certbot renew ; sleep 12h qui ne peut rien émettre n’a aucune raison de tourner.
Pourquoi le saut interne ne peut pas passer en clair #
La tentation, une fois le TLS terminé par le frontal, est de faire parler le frontal à nginx en HTTP clair. Le template nginx du sous-module l’interdit, par deux gardes qui se cumulent.
Le premier décide de la redirection vers HTTPS :
# src/docker-nginx/templates/default.conf.template — upstream, en lecture seule
geo $is_docker_internal {
default 0;
172.16.0.0/12 1;
}
map "$allow_internal_http:$is_docker_internal" $base_redirect_to_https {
"1:1" 0; # HTTP explicitement autorisé ET requête interne Docker
default 1; # sinon, redirection
}NGINX_ALLOW_INTERNAL_HTTP=1 — activé à l’épisode 4 pour les conteneurs QGIS éphémères — ne lève la redirection que pour 172.16.0.0/12. Un frontal situé sur un autre VLAN n’en fait pas partie : il reçoit un 302, et si on l’a configuré pour parler en clair, on obtient une boucle.
Ces deux blocs sont au niveau http, pas dans un server. On ne peut donc pas les contourner depuis config.d/ ni instance.d/, qui sont inclus à l’intérieur des blocs server : la redirection est décidée avant que vos ajouts n’aient voix au chapitre.
Le second garde est plus radical, et présent dans les deux blocs server :
# src/docker-nginx/templates/default.conf.template — upstream, en lecture seule
if ($http_host !~ "${QFIELDCLOUD_HOST}(:${WEB_HTTPS_PORT})?") {
return 444;
}Le frontal doit préserver l’en-tête Host d’origine. Traefik le fait par défaut ; un frontal qui le réécrit avec le nom du backend obtient un 444, c’est-à-dire une connexion fermée sans un octet d’explication. C’est exactement la panne de l’épisode 4, vue de l’autre côté.
Le montage réel #
graph LR
C["client
(navigateur, QField)"] -->|"HTTPS · Let's Encrypt
*.webgis.allfabox.fr"| T["frontal Traefik
VLAN exposé"]
T -->|"HTTPS · CA privée
Host préservé"| N["nginx
VLAN interne"]
N -->|"HTTP · réseau Compose"| A["app
(gunicorn)"]
classDef pub fill:#42a5f533,stroke:#42a5f5,stroke-width:2px
classDef priv fill:#ff704333,stroke:#ff7043,stroke-width:2px
class C,T pub
class N,A priv
Le certificat du saut interne n’est pas un Let’s Encrypt de secours : c’est celui que le service mkcert de la stack upstream génère au premier démarrage, signé par une CA privée dont la racine est dans src/conf/nginx/certs/rootCA.pem. Le frontal doit soit faire confiance à cette racine, soit ne pas vérifier ce saut. C’est un choix à assumer explicitement, pas à découvrir six mois plus tard.
Attention : dès qu’un frontal est devant, toutes les adresses IP de vos journaux nginx deviennent la sienne :
{"ip":"10.99.99.10","method":"GET","status":200,"uri":"/api/v1/status/",
"host":"qfieldcloud.webgis.allfabox.fr","upstream_addr":"172.18.0.9:8000"}La règle deny 34.215.13.216 du template upstream ne protège plus de rien, et toute limitation de débit assise sur l’IP voit un client unique. Pire, django-axes non plus ne voit pas la vraie adresse : il attribue toutes les tentatives de connexion à l’IP du conteneur nginx.
$ AXES_BEHIND_REVERSE_PROXY (absent)
$ AXES_IPWARE_PROXY_COUNT None
Le verrouillage reste correct par utilisateur — AXES_LOCKOUT_PARAMETERS = [['username', 'ip_address']] — donc rien n’est cassé fonctionnellement. Mais toute lecture par IP est sans valeur. QFieldCloud n’expose pas ces réglages dans le .env : les corriger demande un fichier de settings monté depuis l’override.
Retirer certbot sans toucher au sous-module #
La règle de la série tient : git -C src status doit rester vide. On ne supprime donc pas le service, on lui donne un profil que personne n’active :
# docker-compose.override.yml
certbot:
# Le TLS public est terminé par le frontal. Ce certbot-là n'a jamais rien
# à émettre. `profiles` est une liste : la valeur ci-dessous s'ajoute à
# celles du sous-module (il n'en a aucune), et un service porteur d'un
# profil non activé n'est pas démarré.
profiles: ["never"]Le service disparaît de docker compose config --services. Mais attention :
Astuce : docker compose up -d --remove-orphans ne supprime pas le conteneur déjà en marche. Compose ne considère pas comme orpheline une instance simplement exclue par un profil : elle continue de tourner, invisible dans la configuration mais bien présente dans docker ps. Il faut la nommer, en réactivant le profil le temps de la commande :
$ cd src && docker compose --env-file ../.env --profile never rm -sf certbot
Container qfieldcloud-certbot-1 Stopped
Container qfieldcloud-certbot-1 Removed
Restent les trois variables LETSENCRYPT_*. On pourrait les supprimer — scripts/check_envvars.py reste content — mais le bloc environment: du service nginx, dans le sous-module, les nomme encore, et leur absence ajoute trois avertissements Compose à chaque commande. On les garde donc vidées, avec un commentaire qui dit pourquoi. Au passage, cela retire une adresse de courriel personnelle de l’environnement d’un conteneur qui n’en a aucun usage.
« Vidées » veut dire : la clé reste, la valeur part. Dans le .env :
# .env
# --- Certbot retiré de la stack (voir docker-compose.override.yml) ---
# Le TLS public est terminé par le frontal. Ces trois variables ne servent plus
# à rien : aucun certbot ne tourne. On les garde VIDES et non supprimées parce
# que le bloc `environment:` du service nginx (dans le sous-module) les nomme
# encore : les supprimer ajoute trois avertissements Compose à chaque commande.
LETSENCRYPT_EMAIL=""
LETSENCRYPT_RSA_KEY_SIZE=""
LETSENCRYPT_STAGING=""Les guillemets sont facultatifs, LETSENCRYPT_EMAIL= suffit. En revanche, ne commentez pas la ligne : pour Compose, une variable commentée est une variable absente, et l’avertissement revient. La différence se vérifie d’une commande :
$ cd src
$ docker compose --env-file ../.env config -q # les trois lignes vidées
# (rien : aucun avertissement)
$ docker compose --env-file ../.env-sans config -q # les trois lignes supprimées
warning The "LETSENCRYPT_STAGING" variable is not set. Defaulting to a blank string.
warning The "LETSENCRYPT_EMAIL" variable is not set. Defaulting to a blank string.
warning The "LETSENCRYPT_RSA_KEY_SIZE" variable is not set. Defaulting to a blank string.
Ce qu’il faut retenir #
- Derrière un frontal, le certbot de la stack n’a rien à émettre :
profiles: ["never"]le retire sans toucher à l’upstream. -
up -d --remove-orphansne supprime pas un service exclu par un profil ; il faut--profile <nom> rm -sf. - Le saut interne reste en TLS :
NGINX_ALLOW_INTERNAL_HTTPne couvre que172.16.0.0/12, et lesgeo/mapne sont pas contournables depuisconfig.d/. - Le frontal doit préserver l’en-tête
Host, sous peine de444muet. - Toutes les IP de vos journaux — y compris celles vues par
django-axes— deviennent celle du frontal.
Image de couverture : Scott Blake sur Unsplash.