Aller au contenu
  1. Posts/

QFieldCloud auto-hébergé [E04] : du dossier vide au premier paquet QField

Sommaire
QFieldCloud auto-hébergé - Cet article fait partie d'une série.
Partie 4: Cet article

Introduction
#

Les trois articles précédents ont raconté une instance montée à ma main, sa remise à niveau douloureuse, puis le plan que j’en ai tiré. Le troisième se terminait sur une promesse : « il me reste à appliquer ce plan à ma propre instance ». C’est fait, et cet article est le compte rendu de l’opération — dossier vide, git init, jusqu’au premier paquet QField sorti d’un conteneur QGIS éphémère.

Épisode 3 — un déploiement qui ne crée pas de dette

Mais un simple journal de bord n’aurait pas mérité un quatrième article. Ce qui manque à la littérature francophone sur le sujet — et j’y inclus l’excellent tutoriel de Geotribu et le retour d’expérience de 3liz, qui m’ont tous deux servi — ce n’est pas la suite des commandes. C’est ce qu’il y a entre elles.

On y lit qu’il faut lancer docker compose up -d --build, puis migrate, puis collectstatic. On y voit la liste des conteneurs. On n’y trouve nulle part la réponse aux trois questions qu’un exploitant se pose le lendemain de la mise en service :

  • quand un agent télécharge son projet depuis le terrain, par où passent les octets ?
  • quand un job de packaging démarre, qui lance quoi, avec quelle mémoire, et pour combien de temps ?

Info : Un job de packaging, c’est quoi, et pourquoi ? — QField, sur le terrain, ne sait pas ouvrir un projet QGIS branché sur une base PostGIS distante ni sur une couche WMS : le téléphone est souvent hors réseau, et le format d’un projet de bureau n’est pas taillé pour ça. QFieldCloud fabrique donc, à la demande, une version embarquable du projet : un GeoPackage unique qui contient les données des couches, et un .qgz allégé qui pointe dessus. C’est ce couple que QField télécharge, et c’est ce qu’on appelle le paquet.

Cette fabrication ne peut pas se faire dans Django : elle exige un vrai QGIS, avec ses pilotes, ses grilles de reprojection et son moteur de rendu. D’où le mécanisme central de cette stack : un conteneur QGIS jetable, créé pour un job puis détruit. Il démarre dans trois cas — quand un agent demande à récupérer un projet (pull), quand un projet vient d’être téléversé et qu’il faut le lire pour en extraire ses couches et sa vignette (process_projectfile), et quand les modifications rapportées du terrain (les deltas) doivent être appliquées aux données (apply_deltas).

Autrement dit : le plan 2 décrit ci-dessous, c’est le produit. Une instance dont l’interface web répond parfaitement mais dont les jobs ne partent pas est une instance qui ne sert à rien.

  • et quand ça casse, où est-ce que ça casse ?
Cet article déploie l’instance et répond à ces trois questions en même temps, parce que ce sont les mêmes. Tous les chiffres, toutes les sorties et toutes les pannes qui suivent proviennent d’une instance réellement montée le 3 septembre 2026 sur un conteneur LXC de 8 Go et 2 vCPU.


Info : L’instance déployée ici est strictement standalone : tout ce qu’elle utilise vient du dépôt upstream — PostGIS, stockage objet, WebDAV et serveur de courriel embarqués. Brancher un S3 tiers, un serveur PostgreSQL/PostGIS externe — celui de l’application, et éventuellement celui qui héberge vos données métier —, un Nextcloud, un vrai relais SMTP et un frontal Traefik fera l’objet du cinquième et dernier article de la série.

Table des matières
#

Ce que vous déployez : trois plans, pas quinze services
#

Quinze services, c’est un inventaire. Trois plans, c’est un modèle mental — et il tient sur une serviette.

La première fois qu’on lance docker compose ps sur une instance QFieldCloud, on voit une liste. Une liste ne dit rien : elle met sur le même plan memcached et worker_wrapper, alors que l’un est une commodité et l’autre le cœur du produit.

La bonne lecture est différente. QFieldCloud est un logiciel à trois plans, et chaque service appartient à un seul d’entre eux.

graph TB
    subgraph P1["PLAN 1 · LA REQUÊTE"]
        direction TB
        L1["synchrone · quelques millisecondes
ce que voit l'utilisateur"] N["nginx
(porte d'entrée HTTPS)"] --> A["app
(Django + gunicorn)"] A --> M["memcached
(cache)"] end subgraph P2["PLAN 2 · LE TRAVAIL"] direction TB L2["asynchrone · secondes à minutes
ce qui fabrique les paquets QField"] W["worker_wrapper
(boucle de dépilage)"] -.->|"docker.sock"| Q["conteneur QGIS éphémère
(qgis3 / qgis4)"] O["ofelia
(cron)"] -->|"runcrons
@every 1m"| A end subgraph P3["PLAN 3 · L'ÉTAT"] direction TB L3["persistant · survit à tout
ce qu'on sauvegarde"] D[("db
PostGIS")] S[("rustfs
S3")] end A --> D A --> S W --> D Q -->|"API HTTP"| N classDef requete fill:#42a5f533,stroke:#42a5f5,stroke-width:2px classDef travail fill:#ffb30033,stroke:#ffb300,stroke-width:2px classDef etat fill:#ff704333,stroke:#ff7043,stroke-width:2px class N,A,M requete class W,Q,O travail class D,S etat style L1 fill:none,stroke:#42a5f5,stroke-width:1px,stroke-dasharray:4 3 style L2 fill:none,stroke:#ffb300,stroke-width:1px,stroke-dasharray:4 3 style L3 fill:none,stroke:#ff7043,stroke-width:1px,stroke-dasharray:4 3 style P1 fill:none,stroke:#42a5f5,stroke-width:2px style P2 fill:none,stroke:#ffb300,stroke-width:2px style P3 fill:none,stroke:#ff7043,stroke-width:2px

Comment lire ce schéma : les flèches pleines sont des appels réseau, la flèche pointillée est un appel à l’API Docker — worker_wrapper ne parle pas au conteneur QGIS, il le crée. Et la flèche qui remonte du conteneur QGIS vers nginx n’est pas une erreur de dessin : le conteneur éphémère redescend chercher les fichiers du projet par l’API publique, comme le ferait n’importe quel client. Toute la section « La panne qui prouve qu’on a compris » tient dans cette seule flèche.

Plan Ce qui s’y passe Ce qui casse quand il tombe
1 · la requête Authentification, droits, listing de fichiers, téléversement, administration Django Tout est immédiatement hors service
2 · le travail Fabrication des paquets QField, application des deltas de retour, lecture des projets QGIS L’interface répond, les jobs s’empilent en pending, rien ne se voit pendant des heures
3 · l’état Comptes, projets, jobs, versions de fichiers, objets Tout est hors service, et c’est le seul plan qu’on sauvegarde
Astuce : Ce tableau est votre plan de supervision. Le plan 1 se surveille avec un simple GET /api/v1/status/. Le plan 3 aussi — c’est exactement ce que cette route teste. Le plan 2, lui, n’est couvert par aucune sonde HTTP : une instance dont tous les workers sont morts renvoie sereinement {"database":"ok","storage":"ok"}. Surveillez le nombre de jobs en pending de plus de dix minutes, pas la page d’accueil.

Deux traits de conception expliquent presque tout le reste, et aucun des deux n’est évident :

Il n’y a pas de courtier de messages. Pas de Redis, pas de RabbitMQ, pas de Celery. La file d’attente est une table PostgreSQL, et le dépilage se fait avec un SELECT … FOR UPDATE SKIP LOCKED. C’est un choix, et il est bon : une dépendance de moins, et une file qui est sauvegardée en même temps que le reste.

Le conteneur QGIS n’est pas un service. Il n’existe pas dans docker compose ps. Il est créé, utilisé, puis détruit pour chaque job, par un appel à l’API Docker. Les deux services qgis3 et qgis4 du fichier Compose ne servent qu’à construire les images, et s’arrêtent aussitôt.

Le déploiement, du dossier vide au premier up
#

L’article précédent expose la méthode et ses justifications. Voici son exécution, sans commentaire de méthode — seulement ce qui s’est réellement passé.

Le socle : votre dépôt et le sous-module
#

Alternative : Tout ce que cette section construit à la main — le sous-module, le .gitignore, le fichier d’override, le Makefile, le .env.template et les correctifs décrits plus bas — est disponible en un dépôt prêt à cloner : allfab/qfieldcloud-stack-template. Son README reprend la procédure de démarrage, les variables à changer et les pièges de cet article sous forme de mémo.

Si vous le clonez, repartez d’un historique vierge — rm -rf .git src, puis git init et vous rajoutez le sous-module : les commits de ce dépôt racontent mon instance, avec mon frontal et mes adresses. Le vôtre doit raconter la vôtre, c’est tout l’intérêt du montage. Lisez la suite quand même : le dépôt donne le résultat, l’article donne les raisons.

cd /opt/docker/qfieldcloud-stack     # dossier vide
git init
printf '.env\n' > .gitignore
git submodule add -b release https://github.com/opengisch/QFieldCloud.git src
git -C src checkout v26.26
$ git -C src describe --tags
v26.26
$ git -C src log -1 --format='%H %ad %s' --date=short
51ff29d79e44acdb3de18d6a8c9e43e620203984 2026-08-28 Merge branch 'master' into release

Puis les deux dossiers que Docker créerait en root si on ne les prenait pas de vitesse, et les deux fichiers qui m’appartiennent :

mkdir -p src/conf/certbot src/conf/nginx/config.d
cp src/.env.example .env

Le .env : les vingt lignes qui changent
#

Sur les 83 variables que définit le .env.example, vingt ont été modifiées. Les voici toutes, regroupées par intention.

Variable Valeur retenue Pourquoi
DEBUG 0 Défaut : 1. impératif
ENVIRONMENT production Défaut : development
QFIELDCLOUD_HOST qfieldcloud.webgis.allfabox.fr Sans schéma, sans port, sans slash
DJANGO_ALLOWED_HOSTS + le domaine et l’IP de l’hôte Doit contenir QFIELDCLOUD_HOST
SECRET_KEY, SALT_KEY 64 caractères tirés au sort Jamais les valeurs d’exemple
POSTGRES_PASSWORD, OBJECT_STORAGE_ROOT_*, WEBDAV_PASSWORD idem idem
STORAGES access_key/secret_key alignés sur OBJECT_STORAGE_ROOT_* Sinon createbuckets échoue
WEB_HTTP_PORT / WEB_HTTPS_PORT 8480 / 8443 Un frontal occupera 80/443 plus tard
SMTP4DEV_SMTP_PORT 2525 Voir l’encadré ci-dessous
QFIELDCLOUD_DEFAULT_LANGUAGE fr Défaut : en
QFIELDCLOUD_DEFAULT_TIME_ZONE Europe/Paris Défaut : Europe/Zurich — QFieldCloud est un logiciel suisse. Ce fuseau est celui des horodatages affichés dans l’interface et l’administration : mettez le vôtre
LETSENCRYPT_EMAIL mon adresse LETSENCRYPT_STAGING reste à son défaut 1 : tant que le DNS public ne pointe pas ici, inutile de cogner les quotas
COMPOSE_FILE voir plus bas Le défaut est un profil de développement
NGINX_ALLOW_INTERNAL_HTTP 1 voir la section « panne »
QFIELDCLOUD_ACCOUNT_ADAPTER …AccountAdapterSignUpClosed Défaut : …SignUpOpen, soit l’inscription ouverte à tous. impératif
QFIELDCLOUD_WORKER_QFIELDCLOUD_URL http://qfieldcloud.webgis.allfabox.fr/api/v1/ voir la section « panne »
COMPOSE_FILE=docker-compose.yml:docker-compose.override.standalone.yml:docker-compose.override.prod.yml:../docker-compose.override.yml

Cette ligne est la clé de voûte de tout le montage, et elle mérite qu’on s’y arrête, parce que c’est elle qui rend possible tout ce que raconte l’article précédent. Docker Compose sait fusionner plusieurs fichiers : il lit le premier, puis chacun des suivants par-dessus, les derniers gagnant. COMPOSE_FILE est la variable qui déclare cette pile, séparée par des :. Ici, de bas en haut :

Fichier D’où il vient Ce qu’il apporte
docker-compose.yml sous-module src/ Le socle : les quinze services, leurs images, leurs volumes
docker-compose.override.standalone.yml sous-module src/ Le profil « tout embarqué » : PostGIS, rustfs, webdav, smtp4dev
docker-compose.override.prod.yml sous-module src/ Les ajustements de production : redémarrages, journalisation, certbot
../docker-compose.override.yml mon dépôt, un cran au-dessus Mes modifications à moi, et elles seules

Les trois premiers appartiennent au sous-module : ils sont en lecture seule pour moi, et ils seront remplacés à l’identique au prochain git checkout v26.27. Le quatrième vit dans mon dépôt, d’où le ../ : Compose est lancé depuis src/, il faut donc remonter d’un niveau pour aller le chercher.

Architecture : C’est la ligne qui sépare ce déploiement d’un git clone bricolé. Sans elle, la seule façon de changer un port, une limite mémoire ou une politique de journaux est de modifier un fichier du dépôt upstream — et de porter cette modification à la main, de mémoire, à chaque montée de version. Avec elle, git -C src status reste vide en permanence : tout ce qui m’appartient est dans mon fichier, tout ce qui appartient à opengisch est intact. La section « La panne qui prouve qu’on a compris » en fera la démonstration grandeur nature : huit lignes dans mon override, zéro ligne touchée dans le sous-module.

Notez aussi ce que le défaut du dépôt charge à sa place : docker-compose.override.local.yml, c’est-à-dire un profil de développement — ports de débogage ouverts, rechargement automatique du code. Reprendre le COMPOSE_FILE livré tel quel, c’est mettre une instance de développement en production sans s’en rendre compte.


Erreur : SMTP4DEV_SMTP_PORT vaut 25 par défaut, et smtp4dev publie ce port sur toutes les interfaces. Sur une machine où un agent de transport local écoute déjà sur 127.0.0.1:25 — c’est le cas de beaucoup de Debian —, le démarrage échoue avec un address already in use qui ne nomme pas le coupable. Vérifiez avant de lancer : ss -tlnp | grep ':25 '. Ici, 2525 a réglé la question.

Les deux derniers points de contrôle
#

Le dépôt embarque son propre validateur, et il vaut la peine d’être lancé avant le premier up :

python3 src/scripts/check_envvars.py .env --docker-compose-dir src
Envvar "DEBUG_QGIS_WORKER_HOST_PATH" is defined in the .env file, but not found in any docker-compose file.
Some envvars are not passed properly, 1 problem(s) found.

Une seule variable orpheline, et c’est une variable de débogage sans emploi en v26.26. Il suffit de dire au validateur de l’ignorer, avec --ignored-varnames DEBUG_QGIS_WORKER_HOST_PATH — la variable reste dans le .env, et l’exclusion, écrite dans la commande, documente la décision au passage :

python3 src/scripts/check_envvars.py .env --docker-compose-dir src \
  --ignored-varnames DEBUG_QGIS_WORKER_HOST_PATH
All envvars are ok.
Astuce : L’article précédent proposait d’écarter quatre variables de débogage. Une seule est nécessaire : les trois autres — DEBUG_APP_DEBUGPY_PORT, DEBUG_WORKER_WRAPPER_DEBUGPY_PORT, DEBUG_QGIS_DEBUGPY_PORT — apparaissent bien dans docker-compose.override.local.yml, que le script lit même si votre COMPOSE_FILE ne le charge pas. Le script analyse le dossier, pas votre chaîne de fichiers : ne recopiez pas une liste d’exclusions trouvée ailleurs, faites-la tourner et lisez sa sortie.

Puis la configuration fusionnée, qui vérifie du même coup que le sous-dossier src/ n’a rien cassé :

Previously, in QFieldCloud… — dans l’article précédent, j’écrivais un Makefile de quinze lignes à la racine de mon dépôt, avec cette justification qui pouvait passer pour de la coquetterie : « pour ne plus jamais avoir à me souvenir d’où lancer Compose ni avec quel --env-file ». Le moment est venu de toucher les dividendes. La commande réelle, celle qu’il faudrait taper à la main à chaque fois, est en commentaire ; celle que je tape est à gauche du # :

make config      # cd src && docker compose --env-file ../.env config -q

Trois pièges évités d’un coup, et ce sont exactement les trois qui font perdre une soirée : Compose doit être lancé depuis src/ (sinon les chemins relatifs des build: ne tombent pas juste), avec le .env du dossier parent (celui qui m’appartient, pas celui du sous-module), et le -q demande de valider sans rien afficher — il ne parle que s’il y a un problème.

Et un quatrième piège, qui n’en est pas un mais que tout le monde se pose la première fois : non, make up ne vous laisse pas dans src/. make exécute chaque recette dans un sous-shell, le cd src meurt avec elle, et le terminal reste à la racine — make up puis make down s’enchaînent sans y penser. La contrepartie, c’est que le cd et le docker compose doivent tenir sur la même ligne de recette : sur deux lignes, la seconde repartirait de la racine. D’où le && de la variable COMPOSE.

Le Makefile du dépôt modèle déclare par ailleurs ses cibles en .PHONY :

.PHONY: up down config ps logs migrate check

Sans cette ligne, make cherche un fichier portant le nom de la cible avant de lancer quoi que ce soit. Les noms sont courants — config, logs, check —, et le jour où l’un d’eux existe à la racine, même sous forme de dossier, la commande répond make: 'config' is up to date et ne valide plus rien. Le silence, ici, ressemble trop à un succès.

Revenons à nos moutons, la cd src && docker compose --env-file ../.env config sans le -q, nous retourne pleins de lignes et notamment :

...
context: /opt/docker/qfieldcloud-stack/src/docker-app
device:  /opt/docker/qfieldcloud-stack/src/conf/ca
...

Le device est le contrôle qui compte : c’est le volume des autorités de certification, défini avec ${PWD}. S’il pointait un cran trop haut, rien ne protesterait — et rien ne marcherait.

Les images, puis le démarrage
#

Les deux images QGIS se construisent séparément, parce qu’elles sont énormes et qu’un disque plein en milieu de build est une mauvaise soirée :

cd src
alias dc='docker compose --env-file ../.env'
dc build qgis3 && docker builder prune -f && df -h /
dc build qgis4 && docker builder prune -f && df -h /

Ces quatre lignes sont plus retorses qu’elles n’en ont l’air, décortiquons-les :

Ligne Ce qu’elle fait, et pourquoi
cd src On se place dans le sous-module. C’est la contrainte évoquée juste au-dessus : les directives build: du fichier Compose désignent des dossiers relatifs à celui-ci (docker-app, docker-qgis…). Lancé d’ailleurs, Compose cherche des dossiers qui n’existent pas
alias dc='docker compose --env-file ../.env' Un raccourci de session, rien de plus — mais il encapsule le --env-file ../.env, c’est-à-dire exactement ce que fait le Makefile, pour les commandes ponctuelles qu’on ne veut pas y inscrire. Le ../ désigne mon .env, un cran au-dessus de src/. Attention : un alias ne survit pas à la fermeture du terminal, et n’est pas connu de sudo
dc build qgis3 On construit une seule image, nommément, au lieu de laisser make up tout enchaîner. C’est tout l’intérêt de l’opération : les deux images QGIS pèsent 3,1 et 2,3 Go, et un build qui remplit le disque en cours de route laisse un état à moitié écrit, difficile à diagnostiquer
docker builder prune -f Vide le cache de construction de BuildKit — les couches intermédiaires, qui pèsent facilement autant que l’image finale. Le -f (force) évite la question « êtes-vous sûr ? ». On accepte de reconstruire plus lentement la prochaine fois, en échange d’un disque qui tient
df -h / Le contrôle visuel entre les deux constructions. Si l’espace libre a fondu davantage que prévu, on s’arrête avant de lancer la seconde, plutôt que de la découvrir plantée à 80 %

Et le && entre chaque commande n’est pas décoratif : il enchaîne seulement si la précédente a réussi. Si le build de qgis3 échoue, ni le nettoyage ni le df ne s’exécutent, et le message d’erreur reste la dernière chose affichée à l’écran.

Astuce : Comptez large. Les deux images terminées pèsent 5,4 Go à elles seules (voir le tableau ci-dessous), et le cache de construction en réclame autant pendant l’opération : c’est le seul moment du déploiement où le disque est réellement sollicité. Après quoi la stack au repos est très sobre — et c’est bien pour cela qu’on purge entre les deux.
Image Taille
qfieldcloud-qgis3 3,14 Go
qfieldcloud-qgis4 2,30 Go
qfieldcloud-app 795 Mo
qfieldcloud-worker_wrapper 795 Mo
qfieldcloud-nginx 170 Mo
qfieldcloud-createbuckets 186 Mo

Puis la séquence de mise en service, qui n’a pas bougé d’un pouce par rapport à l’article précédent :

make up                                                    # up -d --build
dc exec app python manage.py migrate
dc run --rm app python manage.py collectstatic --noinput
dc exec --user root app python manage.py compilemessages
dc exec app python manage.py createsuperuser
505 static files copied to '/usr/src/app/staticfiles', 505 post-processed.
processing file django.po in /usr/src/app/qfieldcloud/locale/fr/LC_MESSAGES
Creating regular subscription from 2026-09-03 15:07:29+00:00
Superuser created successfully.
Info : Creating regular subscription est la ligne à ne pas manquer. QFieldCloud attache un abonnement à chaque compte, et une instance auto-hébergée n’y échappe pas : le modèle est présent, seul le paiement ne l’est pas. C’est l’origine du fameux message « subscription inactive » que rapportent régulièrement les auto-hébergeurs. En v26.26, createsuperuser fabrique bien l’abonnement au passage, comme le montre la ligne ci-dessus — mais si le message apparaît un jour, c’est là qu’il faut regarder : administration Django, section Subscriptions.

La porte que personne ne pense à fermer
#

Votre instance est debout, vous avez votre compte administrateur, et vous vous apprêtez à la publier. Allez voir votre page de connexion avant : elle affiche un lien « S’inscrire », et il fonctionne.

https://qfieldcloud.example.org/accounts/signup/

Ce n’est pas un oubli du profil standalone, c’est le défaut du dépôt :

# DEFAULT: qfieldcloud.core.adapters.AccountAdapterSignUpOpen
QFIELDCLOUD_ACCOUNT_ADAPTER=qfieldcloud.core.adapters.AccountAdapterSignUpOpen

Il se défend pour qfield.cloud, qui est un service commercial ouvert. Sur une instance auto-hébergée derrière un frontal public, il signifie que n’importe qui trouvant votre URL peut se créer un compte — et, l’abonnement étant créé automatiquement comme on vient de le voir, disposer d’un espace de travail chez vous. Une ligne suffit :

QFIELDCLOUD_ACCOUNT_ADAPTER=qfieldcloud.core.adapters.AccountAdapterSignUpClosed

Le mot « closed » est trompeur, et il vaut la peine de lire ce que cet adaptateur fait réellement :

# qfieldcloud/core/adapters.py
def is_open_for_signup(self, request: HttpRequest) -> bool:
    """Allow signup only if the user has a valid invitation."""
    if hasattr(request, "session"):
        if request.session.get("account_verified_email"):
            return True
    return False

Ce n’est donc pas un verrou, c’est un passage sur invitation : le account_verified_email est posé en session par la vue qui traite les liens d’invitation. Trois conséquences pratiques :

L’inscription libre fermée — /accounts/signup/ répond toujours 200, mais sans formulaire, et le lien disparaît de la page de connexion
Les invitations continuent de fonctionner, c’est le mode nominal
L’administration Django crée toujours des comptes, comme le dit la docstring

Vérification après bascule, sur l’instance :

$ curl -sk https://qfieldcloud.example.org/accounts/signup/ | grep -c '<form'
0
$ curl -sk https://qfieldcloud.example.org/accounts/login/ | grep -c 'accounts/signup'
0
Attention : L’ordre des opérations compte. Les invitations partent par courriel, et à ce stade votre instance est encore sur smtp4dev, qui n’envoie rien. Fermer l’inscription maintenant laisse donc l’administration Django comme unique moyen de créer un compte, jusqu’à ce qu’un vrai relais SMTP soit branché. C’est le bon ordre malgré tout : mieux vaut une instance dont vous êtes le seul utilisateur pendant une semaine qu’une instance ouverte à l’Internet pendant une nuit.

Le désordre du premier démarrage, et pourquoi il est normal
#

Deux choses surprennent au premier up, et aucune n’est un problème.

Quatre services sont Exited.

$ dc ps -a
app                          Up 9 seconds     8000/tcp
certbot                      Up 9 seconds     80/tcp, 443/tcp
createbuckets                Exited (0)
db                           Up 9 seconds     127.0.0.1:5433->5432/tcp
memcached                    Up 9 seconds     11211/tcp
mirror_transformation_grids  Up 9 seconds
mkcert                       Up 9 seconds
nginx                        Up 6 seconds     127.0.0.1:8480->80/tcp, 127.0.0.1:8443->443/tcp
ofelia                       Up 8 seconds
qgis3                        Exited (0)
qgis4                        Exited (0)
rustfs                       Up 9 seconds (healthy)   0.0.0.0:8009->9000/tcp, 0.0.0.0:8010->9001/tcp
smtp4dev                     Up 9 seconds     0.0.0.0:143->143/tcp, 0.0.0.0:2525->25/tcp, 0.0.0.0:8012->80/tcp
webdav                       Up 9 seconds     0.0.0.0:8020->80/tcp
worker_wrapper               Up 8 seconds

qgis3 et qgis4 ont fait leur travail — construire l’image — et leur commande est un echo QGIS3 built. createbuckets a créé le bucket et rendu la main. mirror_transformation_grids sortira, lui, une fois son téléchargement terminé.

Attention : Les 127.0.0.1: devant les ports de nginx, dans ce listing, ne viennent pas du dépôt upstream — c’est mon override qui les impose, parce que je prévois un frontal. Le profil standalone livré publie nginx sur 0.0.0.0. Et si votre frontal tourne sur une autre machine, ce binding loopback le rendra injoignable : il faut alors publier le port TLS sur l’IP de l’hôte — 10.10.20.70:8443:443 plutôt que 0.0.0.0:8443:443, pour ne pas l’ouvrir sur toutes les interfaces au passage. Le port clair, lui, peut rester sur loopback : personne n’a à l’atteindre.

Et worker_wrapper plante en boucle, avec une erreur qui a de quoi inquiéter :

django.db.utils.ProgrammingError: relation "project_project" does not exist

C’est un simple problème d’ordre. worker_wrapper attend que PostgreSQL réponde — ce qu’il fait consciencieusement, Postgres is ready! ✨ 💅 — puis interroge immédiatement la table des projets, qui n’existera qu’après le migrate lancé une commande plus loin. Son restart: unless-stopped le fait revenir, et il se cale tout seul dès que le schéma est en place.

Attention : Ce plantage est normal au premier démarrage seulement. S’il persiste après un migrate réussi, ce n’est plus un problème d’ordre : c’est que worker_wrapper ne parle pas à la même base que app, ou qu’il est connecté à un réplica en lecture seule — le code refuse explicitement ce cas avec un Expected worker_wrapper to be connected to the master DB node!.

Les quinze services, ce qu’ils font réellement
#

L’article précédent triait le profil standalone. Celui-ci décrit ce que chacun fait pendant que la stack tourne, chiffres à l’appui.
Service Plan Rôle réel Empreinte mesurée au repos
nginx requête Terminaison TLS, pages d’erreur, relais des téléchargements 11,3 Mio
app requête Django sous gunicorn, 3 workers × 3 fils 355 Mio
memcached requête Cache Django par défaut (memcached:11211) 7,3 Mio
worker_wrapper travail Boucle de dépilage, lance les conteneurs QGIS 110 Mio
qgis3 / qgis4 travail Ne tournent pas : ils fabriquent les images
ofelia travail Déclenche manage.py runcrons dans app, chaque minute 43,6 Mio
db état PostgreSQL 17 / PostGIS 3.5 de l’application 50,7 Mio
rustfs état Serveur S3 embarqué, 1.0.0-beta.11 79,5 Mio
createbuckets mise en service Crée le bucket, le versioning, le préfixe public
mirror_transformation_grids mise en service Recopie les grilles PROJ
mkcert mise en service Certificat auto-signé au premier démarrage 3,8 Mio
certbot entretien Boucle de renouvellement, toutes les 12 h 1,7 Mio
smtp4dev dépendance Piège à courriels + interface web 76,4 Mio
webdav dépendance Backend de stockage alternatif 5,6 Mio

Total au repos : environ 1,5 Go de mémoire, sur les 8 Go de la machine. C’est le chiffre à retenir pour dimensionner : la stack au repos est modeste, ce sont les conteneurs QGIS éphémères qui coûtent, et on verra plus loin qu’ils sont plafonnés.

Les trois services qu’on comprend mal
#

ofelia n’est pas un ordonnanceur de sauvegardes. C’est un cron qui lit les labels Docker. Le seul travail qu’on lui a confié est déclaré dans docker-compose.yml, sur le service app :

labels:
  ofelia.enabled: "true"
  ofelia.job-exec.runcrons.schedule: "@every 1m"
  ofelia.job-exec.runcrons.command: python manage.py runcrons

Ce qui tourne alors, toutes les minutes, ce sont quatre tâches Django :

Tâche Ce qu’elle fait
SendNotificationsJob Expédie les courriels de notification en attente
ResendFailedInvitationsJob Renvoie les invitations dont l’envoi a échoué
SetTerminatedWorkersToFinalStatusJob Rattrape les jobs dont le conteneur est mort sans rendre de verdict
DeleteObsoleteProjectPackagesJob Fait le ménage des anciens paquets dans le stockage objet

Les deux dernières sont les importantes. La troisième est votre filet de sécurité contre les jobs fantômes ; la quatrième est la raison pour laquelle votre bucket ne grossit pas indéfiniment à chaque pull. Si ofelia est arrêté, rien ne se voit pendant une semaine, puis le stockage se remplit.

mirror_transformation_grids télécharge beaucoup plus que vous ne croyez. Sa commande est un wget --mirror https://cdn.proj.org/, sans filtre. Sur cette instance :

$ docker exec qfieldcloud-mirror_transformation_grids-1 du -sh /transformation_grids
435.5M  /transformation_grids       ← après 1 minute
$ docker system df -v | grep transformation
qfieldcloud_transformation_grids   2   847MB     ← à la fin

847 Mo, en quatre minutes environ. Ce sont les grilles de transformation de coordonnées de PROJ, montées en lecture seule dans chaque conteneur QGIS : sans elles, les reprojections entre systèmes nationaux perdent en précision. C’est incrémental — les up suivants ne retéléchargent que les nouveautés — mais prévoyez-le sur un lien lent, et ne vous étonnez pas d’un premier démarrage qui « rame » pendant que le reste est déjà debout.

memcached n’est pas facultatif au sens où on l’entend. C’est le cache par défaut de Django, avec ignore_exc: True : s’il tombe, l’application continue de fonctionner, simplement plus lentement. En revanche, c’est aussi lui qui porte les compteurs de limitation de débit. Un cache vidé, ce sont des quotas de tentatives de connexion remis à zéro.

Le chemin d’une requête
#

Un agent télécharge une photo de 8 Mo depuis le terrain. Suivons-la, octet par octet — c’est le mécanisme le plus élégant de la stack, et le plus fragile.

La réponse naïve serait : nginx relaie vers Django, Django lit l’objet dans S3 et le renvoie. Ce n’est pas du tout ce qui se passe, et c’est heureux : Django y passerait son temps et sa mémoire.

sequenceDiagram
    participant C as QField
    participant N as nginx
    participant A as app (Django)
    participant S as rustfs (S3)
    C->>N: GET /api/v1/files/{projet}/photo.jpg/
    N->>A: proxy_pass + X-Forwarded-For
    A->>A: authentification, droits, FileVersion
    A->>A: URL présignée, valable 600 s
    A-->>N: 200 vide
X-Accel-Redirect: /storage-download/
redirect_uri: http://172.17.0.1:8009/… Note over N: location interne,
Authorization et Cookie retirés,
proxy_buffering off N->>S: GET sur l'URL présignée S-->>N: flux de l'objet N-->>C: flux relayé, sans écriture disque

Trois détails de mise en œuvre méritent d’être connus, parce que chacun est une panne en puissance.

1. Le déclencheur est un en-tête, pas un réglage
#

Django ne choisit pas ce chemin en fonction d’ENVIRONMENT ni de DEBUG. Il regarde la présence de l’en-tête X-Forwarded-For :

# qfieldcloud/filestorage/view_helpers.py
if "X-Forwarded-For" in request.headers and not settings.IN_TEST_SUITE:
    
    response = HttpResponse()
    response["X-Accel-Redirect"] = "/storage-download/"
    response["redirect_uri"] = url

Et si cet en-tête est absent, en dehors du mode débogage et des tests, il n’y a pas de repli :

raise Exception(
    "Expected to either run behind nginx proxy, debug mode or within a test suite."
)
Erreur : Traduction opérationnelle, et elle est brutale : en DEBUG=0, tout client qui atteint app:8000 sans passer par nginx reçoit un 500 sur chaque téléchargement de fichier. Pas un 403, pas un message clair — une exception Python. Retenez-la : c’est exactement la panne de la section suivante, et c’est aussi ce qui arrive si vous placez un frontal qui n’ajoute pas X-Forwarded-For.

2. Le location est interne, et il résout par le DNS de Docker
#

location /storage-download/ {
    internal;
    set $redirect_uri "$upstream_http_redirect_uri";
    resolver 127.0.0.11 ipv6=off;
    proxy_max_temp_file_size 0;
    proxy_buffering off;
    proxy_set_header Authorization $webdav_auth;
    proxy_set_header Cookie '';
    proxy_pass $redirect_uri;
}

internal signifie qu’aucun client ne peut appeler cette route directement : seule une redirection interne y mène. Les deux lignes proxy_max_temp_file_size 0 et proxy_buffering off garantissent que le fichier ne touche jamais le disque de nginx : il est relayé au fil de l’eau. Et Authorization est remplacé, Cookie effacé — le jeton QFieldCloud du client ne part jamais vers le stockage objet.

Enfin, resolver 127.0.0.11 est le serveur DNS interne de Docker. C’est la traduction précise de la note laconique du .env.example — « the endpoint_url must be a URL reachable from within docker and the host » : le nom d’hôte de votre stockage objet doit se résoudre dans le réseau Docker de la stack, pas seulement depuis votre poste.

3. Le stockage passe par la passerelle de l’hôte, et pas par le réseau Compose
#

C’est le détail qui déroute le plus, et le .env.example le livre sans l’expliquer :

"endpoint_url": "http://172.17.0.1:8009"

172.17.0.1, c’est l’adresse de docker0, la passerelle de l’hôte. Pourquoi pas http://rustfs:9000, le nom de service, tellement plus propre ?

Parce que cette URL n’est pas seulement lue par app. Elle est recopiée telle quelle dans l’URL présignée, qui sera ensuite utilisée par nginx, par les conteneurs QGIS éphémères, et potentiellement par un client. Elle doit donc désigner le même service depuis tous ces points de vue — d’où le passage par l’hôte, dénominateur commun.

Attention : Conséquence de sécurité, à ne pas manquer : ce choix oblige à publier le port S3 sur toutes les interfaces. Vérification faite sur l’instance, rustfs écoute bien sur 0.0.0.0:8009 — API S3 — et 0.0.0.0:8010 — console web. Ce sont, avec smtp4dev (8012, 2525, 143) et webdav (8020), les seuls ports que le profil standalone expose au LAN sans vous le demander — par opposition à celui de nginx, que vous ouvrirez sciemment le jour où un frontal devra l’atteindre. Sur une machine partagée, c’est un pare-feu à écrire le jour même.

Le chemin d’un job
#

Un job de packaging, de la ligne insérée en base jusqu’au GeoPackage déposé dans le bucket. Sept étapes, un conteneur qui naît et meurt, et un jeton d’API qui expire.
sequenceDiagram
    participant A as app (Django)
    participant D as db (table core_job)
    participant W as worker_wrapper
    participant K as démon Docker
    participant Q as conteneur QGIS éphémère
    participant N as nginx
    A->>D: INSERT job (pending)
    loop toutes les 5 s
        W->>D: SELECT … FOR UPDATE SKIP LOCKED
    end
    D-->>W: le job le plus ancien
    W->>D: status = queued, puis started
    W->>K: containers.run(image, /io/, jeton, mem_limit 1 Go)
    K->>Q: démarrage
    Q->>N: GET /api/v1/files/… (jeton du job)
    N-->>Q: le projet, via X-Accel-Redirect
    Q->>Q: QGIS ouvre, package, écrit /io/feedback.json
    Q->>N: POST du paquet
    Q-->>K: code de sortie
    W->>Q: logs() puis stop() + remove()
    W->>D: status = finished, output, feedback

La file d’attente est une table
#

worker_wrapper exécute python manage.py dequeue, une boucle infinie dont voici le cœur, réellement exécuté toutes les cinq secondes (QFIELDCLOUD_WORKER_DEQUEUE_INTERVAL_S) :

jobs_qs = (
    Job.objects.select_for_update(skip_locked=True)
    .filter(status=Job.Status.PENDING)
    .exclude(
        Q(project_id__in=busy_projects_ids_qs)
        | Q(project__locked_at__isnull=False),
    )
    .order_by("created_at")
)
queued_job = jobs_qs.first()

Quatre propriétés découlent directement de ces quelques lignes, et elles gouvernent tout le comportement de votre instance en charge :

Propriété D’où elle vient Ce que ça implique pour vous
Un seul job à la fois par projet exclude(project_id__in=busy_projects_ids) Dix agents qui synchronisent le même projet sont sérialisés. Multiplier les workers n’y change rien
Plusieurs workers ne se marchent pas dessus select_for_update(skip_locked=True) QFIELDCLOUD_WORKER_REPLICAS peut monter sans risque de double exécution
Aucun job pendant un transfert project__locked_at__isnull=False Un téléversement en cours gèle la file de ce projet — c’est voulu
Ordre strictement chronologique order_by("created_at") Pas de priorité : un gros packaging bloque les petits derrière lui, pour ce projet
Astuce : Le bon réglage de QFIELDCLOUD_WORKER_REPLICAS, c’est le nombre de projets distincts susceptibles d’être synchronisés en même temps, pas le nombre d’agents. Une collectivité avec trois projets de terrain n’a aucun intérêt à dépasser trois workers ; vingt agents sur un seul projet se contenteront très bien d’un.

Le conteneur éphémère, en détail
#

Une fois le job décroché, worker_wrapper appelle l’API Docker — il a la socket montée — et crée un conteneur. Voici l’appel réel, dépouillé :

container = client.containers.run(
    self.get_qgis_image(),          # qfieldcloud-qgis3 ou -qgis4
    command,                        # python3 entrypoint.py package <uuid>
    environment=environment,
    volumes=volumes,
    network=settings.QFIELDCLOUD_DEFAULT_NETWORK,
    detach=True,
    mem_limit=config.WORKER_QGIS_MEMORY_LIMIT,
    cpu_shares=config.WORKER_QGIS_CPU_SHARES,
    labels={"app": , "type": job.type, "job_id": , "project_id": },
)

Sept faits en découlent, et pas un n’est documenté ailleurs :

1 — Le choix entre QGIS 3 et QGIS 4 se fait par projet. Il vient de project.qgis_version, une valeur que le job process_projectfile a lue dans le fichier .qgs lui-même. Si le projet n’a jamais été analysé, le repli est QGIS 3 — le code le dit explicitement : « the safe fallback is to use QGIS 3 until 4.2.x gets widely adopted ».

2 — Les limites de ressources sont réglables à chaud, et pas dans le .env. Elles viennent de django-constance, donc de la base, donc de l’administration Django :

WORKER_QGIS_MEMORY_LIMIT = 1000m
WORKER_QGIS_CPU_SHARES   = 512

Un gigaoctet par conteneur QGIS. C’est le chiffre qui doit gouverner votre dimensionnement : avec QFIELDCLOUD_WORKER_REPLICAS=3, prévoyez 3 Go pour les workers, en plus des 1,5 Go de la stack au repos. Et si un packaging échoue sur un gros projet raster sans message clair, c’est la première valeur à augmenter — dans l’admin, pas dans un fichier.

3 — L’échange de fichiers se fait par un dossier temporaire de l’hôte. Le wrapper crée un mkdtemp dans /tmp et le passe en volume au conteneur QGIS, monté sur /io/. D’où le montage qui intrigue dans docker-compose.yml :

worker_wrapper:
  volumes:
    - ${TMP_DIRECTORY}:/tmp

Ce n’est pas un cache, c’est une contrainte d’identité. Le chemin que worker_wrapper calcule dans son propre /tmp est transmis au démon Docker, qui l’interprète comme un chemin de l’hôte. Les deux doivent donc désigner le même dossier.

Erreur : Changer TMP_DIRECTORY pour /var/tmp/qfc sans réfléchir casse les jobs de façon silencieuse : le montage /var/tmp/qfc:/tmp reste correct côté wrapper, et le chemin /tmp/tmpXXXX passé au démon désigne alors le /tmp de l’hôte, qui est vide. Le conteneur QGIS démarre, ne trouve rien, et le job échoue avec un message qui ne parle pas de volumes. Si vous déplacez ce dossier — sur un disque plus rapide, par exemple —, c’est bien TMP_DIRECTORY qu’il faut changer, et rien d’autre : le montage est écrit pour rester cohérent.

4 — Le conteneur QGIS ne touche jamais la base. Il reçoit QFIELDCLOUD_URL et un jeton, et parle à l’application en HTTP, par l’API publique. C’est une frontière de sécurité réelle : un projet QGIS piégé s’exécute dans un conteneur qui n’a ni identifiants PostgreSQL, ni clés S3, ni accès à la socket Docker.

5 — Le jeton est jetable. Il est créé pour le job, de type WORKER, et expire à la fin du délai imparti plus soixante secondes :

token_expires_at = timezone.now() + timedelta(
    seconds=self.container_timeout_secs + TOKEN_EXPIRATION_TIME_BUFFER_S
)

6 — Les secrets du projet sont injectés là, et seulement là. Les secrets de type PGSERVICE sont concaténés dans PGSERVICE_FILE_CONTENTS, que l’entrypoint.py écrit dans ~/.pg_service.conf. C’est le mécanisme qui a remplacé l’ancien service geodb, décrit dans l’article précédent.

7 — Le conteneur est détruit, mais ses journaux sont conservés. container.logs() est lu avant le stop() et le remove(), puis rangé dans le champ output du job. C’est là qu’il faut chercher quand un packaging échoue : dans l’administration Django, pas dans docker logs.

Les sept étapes d’un packaging, telles qu’elles se sont déroulées
#

Le conteneur écrit un feedback.json dans /io/, que le wrapper relit. Voici son contenu réel, pour le job de packaging de mon projet de test :

start_qgis_app               Start QGIS Application
download_project_directory   Download Project Directory
qgis_layers_data             QGIS Layers Data
package_project              Package Project
qfield_layer_data            Packaged Layers Data
stop_qgis_app                Stop QGIS Application
upload_packaged_project      Upload Packaged Project

L’étape 2 est celle qui traverse tout le chemin décrit à la section précédente : c’est le conteneur QGIS qui rappelle l’API pour récupérer le .qgs et ses données. Retenez-la, elle va nous occuper.

Et les durées, mesurées sur les champs docker_started_at / docker_finished_at du job :

process_projectfile   finished   total=2,07 s   docker=2,02 s
package               finished   total=1,44 s   docker=1,41 s

Deux secondes pour un projet minuscule — une couche, un point. Le coût est presque entièrement dans le démarrage de QGIS ; il ne croît pas linéairement avec la taille du projet.

La panne qui prouve qu’on a compris
#

Le premier projet téléversé a produit un job en échec. Le diagnostic complet tient en trois lectures de code, et le correctif en huit lignes de mon fichier d’override.

Le symptôme
#

Après avoir créé un projet et téléversé un .qgs fabriqué pour l’occasion, un job apparaît, tourne, et échoue :

$ curl … /api/v1/jobs/?project_id=…
process_projectfile   failed

Le conteneur QGIS, lui, s’est parfaitement exécuté — code de sortie 0, QGIS démarré :

Finished execution with code 0, logs:
15:08:11.760 root  INFO  Starting QGIS app version 34413 (2c8a7782a96)...
15:08:11.909 root  INFO  QGIS app started!

C’est le feedback.json qui livre la cause :

"error_type":  "API_INTERNAL_SERVER_ERROR",
"error_class": "QfcRequestException",
requests.exceptions.HTTPError: 500 Server Error: Internal Server Error for url:
  http://app:8000/api/v1/files/497a2200-…/chantier.qgs/

Et côté app, l’exception attendue :

"level":"ERROR",
"message":"Expected to either run behind nginx proxy, debug mode or within a test suite.",
"filename":"view_helpers.py","lineno":370

Le diagnostic
#

C’est exactement la panne annoncée deux sections plus haut. Le conteneur QGIS appelle l’application à l’adresse que lui donne QFIELDCLOUD_WORKER_QFIELDCLOUD_URL — dont la valeur livrée et documentée dans le .env.example est :

# QFieldCloud URL used within the worker as configuration for qfieldcloud-sdk.
# DEFAULT: "http://app:8000/api/v1/"
QFIELDCLOUD_WORKER_QFIELDCLOUD_URL=http://app:8000/api/v1/

Cette adresse court-circuite nginx. Pas de nginx, pas de X-Forwarded-For ; pas de X-Forwarded-For et DEBUG=0, pas de repli : 500.

Attention : Le défaut du dépôt est donc un défaut de développement, comme l’est le COMPOSE_FILE livré. En DEBUG=1 il fonctionne, parce que Django sert alors le fichier lui-même. Passez DEBUG=0 — ce que tout le monde vous dit de faire, et à juste titre — et tous les jobs qui lisent un fichier de projet échouent. C’est-à-dire tous.

Le correctif, et pourquoi il n’est pas évident
#

Il faut faire passer les workers par nginx. Sauf que nginx ne se laisse pas faire, et c’est délibéré. Son fichier de configuration contient, dans les deux blocs server :

# prevent access by IP
if ($http_host !~ "${QFIELDCLOUD_HOST}(:${WEB_HTTPS_PORT})?") {
    return 444;
}

Un seul en-tête Host est accepté : celui de QFIELDCLOUD_HOST. Toute autre valeur reçoit un 444 — la connexion est fermée sans réponse. J’ai vérifié les trois candidats depuis un conteneur posé sur le réseau de la stack :

URL essayée Résultat
http://nginx/api/v1/status/ rien — connexion fermée (444)
https://nginx/api/v1/status/ rien — même cause, malgré server_name … nginx
http://qfieldcloud.webgis.allfabox.fr/api/v1/status/ 302 vers https://…:8443/ — inaccessible depuis le réseau Docker
https://qfieldcloud.webgis.allfabox.fr/… (résolu vers l’IP de nginx) 200

Le server_name ${QFIELDCLOUD_HOST} nginx; du bloc HTTPS est un faux ami : le test sur $http_host, quelques lignes plus bas, annule l’autorisation qu’il semblait accorder.

Il faut donc deux choses, et l’upstream a prévu les deux — sans les câbler ensemble :

Un — que le nom du domaine résolve dans le réseau Docker. C’est un alias réseau, et c’est précisément le genre de chose pour laquelle mon fichier d’override existe :

# /opt/docker/qfieldcloud-stack/docker-compose.override.yml
services:
  nginx:
    # Les conteneurs QGIS éphémères rappellent l'API pour télécharger les
    # fichiers du projet, et doivent passer par nginx (X-Accel-Redirect).
    # Or nginx n'accepte qu'un seul en-tête Host : QFIELDCLOUD_HOST.
    # On lui donne ce nom sur le réseau.
    networks:
      default:
        aliases:
          - qfieldcloud.webgis.allfabox.fr

Deux — que nginx ne renvoie pas les requêtes internes vers HTTPS. Cette variable existe, elle est documentée, et elle a été écrite pour ce cas exact :

NGINX_ALLOW_INTERNAL_HTTP=1

Sa mise en œuvre dans le modèle nginx est instructive :

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;   # plain http autorisé, et requête venue du réseau Docker
    default 1;
}

L’autorisation n’est donnée que si les deux conditions sont réunies : le réglage est actif et la requête vient du réseau interne. Une requête publique en clair continue d’être redirigée vers HTTPS. C’est un compromis honnête, et il évite d’avoir à distribuer la CA de mkcert dans les conteneurs QGIS.

Il reste à pointer les workers vers ce chemin :

QFIELDCLOUD_WORKER_QFIELDCLOUD_URL=http://qfieldcloud.webgis.allfabox.fr/api/v1/

La vérification
#

make config && make up

Puis un nouveau téléversement du même fichier, et un job de packaging déclenché à la main par l’API :

process_projectfile=failed | package=finished | process_projectfile=finished

Le premier échec est celui d’avant le correctif — je le laisse en base, il documente la panne. Les deux suivants sont passés.

Succès : package=finished. Un conteneur qfieldcloud-qgis3 est né, a téléchargé le projet à travers nginx, l’a ouvert dans QGIS 3.44.13, en a tiré un GeoPackage et un .qgz, les a téléversés, puis a été détruit. C’est le seul test qui compte : {"database":"ok","storage":"ok"} ne dit rien du plan 2.

Et le commit, parce que c’est tout l’intérêt d’avoir un dépôt :

$ git log --oneline
0660709 Les workers passent par nginx : alias réseau + NGINX_ALLOW_INTERNAL_HTTP
986efc5 Instance webgis sur QFieldCloud v26.26 (profil standalone)
Architecture : Cette panne est la meilleure justification de la méthode de l’article précédent que j’aie rencontrée. Le correctif tient dans deux variables du .env et huit lignes de mon override ; pas une ligne du dépôt upstream n’a été touchée, et git -C src status est resté vide. Si j’avais recopié docker-compose.yml, j’aurais « corrigé » le fichier en place, et j’aurais rejoué ce raisonnement à chaque montée de version — ou, plus vraisemblablement, je l’aurais oublié et j’aurais réintroduit la panne.

Ce que la stack écrit sur le disque
#

Trois endroits, et un seul mérite votre sauvegarde. Voici les trois, mesurés.

Le bucket, pour de vrai
#

Voici le contenu intégral du stockage objet après un projet, deux téléversements du même fichier et un packaging :

33 Kio  projects/497a2200-…/files/chantier.qgs/v20260903150809-9f654330
33 Kio  projects/497a2200-…/files/chantier.qgs/v20260903151031-4bf024d5
96 Kio  projects/497a2200-…/files/releves.gpkg/v20260903150809-01af4fd3
603 B   projects/497a2200-…/meta/thumbnail_v20260903171035_d47e8851.png
8,3 Kio projects/497a2200-…/packages/f96dd322-…/chantier_qfield.qgz
100 Kio projects/497a2200-…/packages/f96dd322-…/data.gpkg

Tout est là, et tout s’explique :

Préfixe Contenu Reconstructible ?
files/<nom>/v<horodatage>-<empreinte> Les fichiers du projet, une clé par version Non — c’est votre donnée
meta/thumbnail_*.png La vignette du projet, produite par process_projectfile Oui
packages/<uuid du job>/… Le paquet QField : data.gpkg + le .qgz allégé Oui, à chaque pull

Deux enseignements, à ne pas manquer.

Le versionnement est applicatif, pas S3. Les deux versions de chantier.qgs sont deux clés distinctes, chacune horodatée et empreintée, avec une ligne correspondante dans la table FileVersion. Le versioning de bucket que createbuckets active par-dessus n’est qu’une ceinture supplémentaire pour le soft-delete — pas le mécanisme principal. C’est ce qui rend un Garage, dépourvu de versioning, viable pour une instance neuve : l’article précédent le disait, le listing le montre.

Le paquet est indexé par l’identifiant du job, pas par une version « courante ». Chaque packaging crée un nouveau dossier ; c’est DeleteObsoleteProjectPackagesJob, appelé par ofelia, qui supprime les anciens. Sur un projet branché sur PostGIS — qui est repackagé à chaque pull, comme l’expliquait l’article précédent —, cette tâche n’est pas un luxe.

Les neuf volumes, mesurés
#

$ docker system df -v | grep qfieldcloud_
qfieldcloud_transformation_grids    847,0 Mo
qfieldcloud_postgres_data           118,3 Mo
qfieldcloud_static_volume            29,0 Mo
qfieldcloud_rustfs_data             309,7 ko
qfieldcloud_smtp4dev_data            98,3 ko
qfieldcloud_media_volume                  0 B
qfieldcloud_certbot_www                   0 B
qfieldcloud_webdav_data                   0 B
qfieldcloud_custom_ca_certificates        —      (montage lié sur src/conf/ca)

Sur une instance vide, les grilles PROJ représentent à elles seules 85 % du volume de données. Elles sont intégralement reconstructibles, et n’ont donc rien à faire dans votre sauvegarde. En pratique, votre plan de sauvegarde porte sur deux volumespostgres_data par un dump logique, rustfs_data par un miroir S3 — et sur le .env, qui contient les clés sans lesquelles les champs chiffrés de la base sont perdus.

Les journaux, et la mauvaise surprise
#

C’est le poste que personne ne regarde, et il est spectaculaire. Le docker-compose.yml définit deux politiques de journalisation : une ancre &default-logging à 100m × 10, et une politique locale plus généreuse sur app et nginx. En additionnant ce que Compose accorde réellement :

app              1000m × 10 =  9,8 Go
nginx            1000m × 10 =  9,8 Go
qgis3             100m × 10 =  1,0 Go
qgis4             100m × 10 =  1,0 Go
worker_wrapper    100m × 10 =  1,0 Go
                             ────────
TOTAL borné                   22,5 Go

Sauf que ce total ne compte que les services qui déclarent une limite. Les sept autres — db, ofelia, memcached, smtp4dev, webdav, mkcert, mirror_transformation_grids — n’en déclarent aucune et retombent sur le défaut du démon Docker : json-file, sans limite. Ces 22,5 Go sont donc un plancher, pas un plafond.

Erreur : 22,5 Go de journaux autorisés pour cinq services, et aucune limite du tout pour les sept autres, sur une instance dont les données tiennent dans 1 Go. Sur un LXC de 64 Go qui héberge aussi autre chose, c’est un remplissage de disque qui vous tombera dessus un dimanche. Ajoutez-y le fait que l’override standalone lance PostgreSQL avec log_statement=alltoutes les requêtes, journalisées —, et vous avez la recette complète.

Les deux corrections tiennent, là encore, dans mon fichier d’override :

services:
  db:
    ports: !override
      - "127.0.0.1:5433:5432"
    # L'override standalone journalise TOUTES les requêtes (log_statement=all).
    command:
      - postgres
      - -c
      - log_statement=ddl
      - -c
      - log_destination=stderr
      - -c
      - shared_buffers=256MB

Pour les journaux Docker eux-mêmes, une clé logging: dans le même fichier ramène l’ensemble à quelque chose de raisonnable — mais elle doit couvrir tous les services, pas seulement app et nginx : ce sont ceux qui ne déclarent rien qui peuvent remplir le disque sans borne. Et même exhaustive, elle laisse un trou : les conteneurs QGIS éphémères sont créés par worker_wrapper via l’API Docker, pas par Compose, et ne connaissent que le défaut du démon. Le filet global se pose donc dans /etc/docker/daemon.json, sur la machine. Je l’ai laissé de côté ici : sur cette instance de test, je veux justement des journaux longs.

L’instance en état de marche
#

Les cinq contrôles que je passe avant de déclarer une instance en service — et ce qu’ils ont réellement renvoyé.

1 — L’API répond, et elle voit ses deux dépendances.

$ curl -sk https://qfieldcloud.webgis.allfabox.fr:8443/api/v1/status/
{"database":"ok","storage":"ok","status_page_url":"https://status.qfield.cloud/",
 "incident_message":null, …}

2 — Les pages servies par nginx le sont bien par nginx.

/                 302     (redirection vers la connexion)
/admin/           302
/api/v1/status/   200
/pages/404.html   200     ← servie par nginx, sans toucher Django

3 — Le sous-module est propre, et mon dépôt sait ce qu’il déploie.

$ git -C src status --short
                          ← vide : zéro dette
$ git log --oneline
0660709 Les workers passent par nginx : alias réseau + NGINX_ALLOW_INTERNAL_HTTP
986efc5 Instance webgis sur QFieldCloud v26.26 (profil standalone)

Le point remarquable, c’est que src/conf/nginx/certs/ contient désormais quatre fichiers créés par mkcert — dont une autorité racine — et que git -C src status reste vide malgré tout. Le .gitignore upstream les couvre. C’est ce qui rendra le prochain git checkout v26.27 sans histoire.

4 — Le .env est cohérent avec les fichiers Compose.

$ python3 src/scripts/check_envvars.py .env --docker-compose-dir src \
    --ignored-varnames DEBUG_QGIS_WORKER_HOST_PATH
All envvars are ok.

5 — Un job de bout en bout aboutit. C’est le seul contrôle qui teste le plan 2, et donc le seul qui ait vraiment de la valeur :

package=finished
Astuce : Ce cinquième contrôle n’a pas besoin de QGIS Desktop ni de QFieldSync. Trois appels à l’API suffisent : POST /api/v1/auth/login/ pour obtenir un jeton, POST /api/v1/projects/ pour créer un projet, POST /api/v1/files/<uuid>/<nom>/ pour y téléverser un .qgs. Le job process_projectfile part tout seul. C’est un test de recette de trois lignes de curl, parfait pour une sonde de supervision ou une vérification après mise à jour.

Et si vous n’avez pas de projet QGIS sous la main, l’image que vous venez de construire sait en fabriquer un — c’est la même version de QGIS que celle qui traitera vos jobs, donc un projet valide par construction :

# make_project.py — à poser dans un dossier vide
from qgis.core import (QgsApplication, QgsProject, QgsVectorLayer, QgsFields,
                       QgsField, QgsVectorFileWriter, QgsWkbTypes, QgsFeature,
                       QgsGeometry, QgsPointXY, QgsCoordinateReferenceSystem,
                       QgsCoordinateTransformContext)
from qgis.PyQt.QtCore import QVariant

QgsApplication.setPrefixPath("/usr", True)
app = QgsApplication([], False)
app.initQgis()

fields = QgsFields()
fields.append(QgsField("id", QVariant.Int))
fields.append(QgsField("nom", QVariant.String))
crs = QgsCoordinateReferenceSystem("EPSG:2154")

opts = QgsVectorFileWriter.SaveVectorOptions()
opts.driverName, opts.layerName = "GPKG", "releves"
w = QgsVectorFileWriter.create("/io/releves.gpkg", fields, QgsWkbTypes.Point,
                               crs, QgsCoordinateTransformContext(), opts)
f = QgsFeature(fields)
f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(843000, 6278000)))
f.setAttributes([1, "borne 1"])
w.addFeature(f)
del w

layer = QgsVectorLayer("/io/releves.gpkg|layername=releves", "releves", "ogr")
assert layer.isValid()
p = QgsProject.instance()
p.setCrs(crs)
p.addMapLayer(layer)
p.write("/io/chantier.qgs")
app.exitQgis()
docker run --rm -v "$PWD:/io" -e QT_QPA_PLATFORM=offscreen \
  qfieldcloud-qgis3 python3 /io/make_project.py
QGIS 3.44.13-Solothurn
Info : Les fichiers produits appartiennent à root — le conteneur QGIS tourne sous cet utilisateur. Ils restent lisibles, donc le téléversement passe sans rien faire ; c’est le jour où vous voudrez les modifier ou les supprimer que le chown vous manquera.

Ce qui reste dehors
#

Cette instance est complète et fonctionnelle. Elle n’est pas encore en production, et il faut dire précisément pourquoi.
Ce qui manque Pourquoi ça ne va pas en l’état Article 5
Certificat mkcert produit un certificat auto-signé, que seul l’hôte reconnaît Traefik en frontal, certificat réel
Courriel smtp4dev n’envoie rien : aucune vérification d’adresse, aucune réinitialisation de mot de passe n’arrive à son destinataire Relais SMTP
Stockage objet rustfs est en 1.0.0-beta.11, et son port est exposé au LAN S3 externe (Garage), et le port qui disparaît
Base db est très bien — mais elle vit dans le même LXC que tout le reste À discuter : garder ou externaliser
Sauvegardes Aucune Dump + miroir, et leur restauration testée
Journalisation plafond non borné, log_statement à revoir Plafonds dans l’override et dans daemon.json
Pare-feu Quatre services publient sur 0.0.0.0 Fermeture explicite
Attention : Une réserve de sécurité, qui vaut pour toute instance QFieldCloud, y compris en production : worker_wrapper tourne en root et monte /var/run/docker.sock. C’est le contrôle complet du démon Docker, donc de la machine. Le fichier upstream le reconnaît dans un commentaire : « TODO change me to least privileged docker-capable user on the host ». Ce n’est pas un défaut de votre déploiement, c’est une propriété du produit — mais elle interdit de traiter ce conteneur comme un composant anodin. Le LXC dédié apporte ici un cloisonnement fonctionnel, pas une frontière de sécurité : s’il est privilégié — le cas par défaut de bien des installations, et celui de mon instance — root dans le conteneur reste root sur l’hôte, et la chaîne worker_wrapper → LXC → hôte se referme d’un bout à l’autre. La vraie parade est un LXC non privilégié (Docker y tourne, moyennant nesting=1 et keyctl=1, et une reprise des UID sur les volumes existants) ou une VM. Vérifiez le vôtre : cat /proc/self/uid_map doit montrer autre chose que l’identité.

Conclusion
#

Le plan tenait. Ce que je ne savais pas, c’est qu’il tiendrait aussi pour la panne.

Ce que je retiens de ce déploiement :

  • Trois plans, pas quinze services. La requête (nginx, app, memcached), le travail (worker_wrapper et ses conteneurs éphémères, ofelia), l’état (db, rustfs). Chaque service appartient à un seul plan, et chaque panne se range dans un seul.
  • {"database":"ok","storage":"ok"} ne teste que deux plans sur trois. Une instance dont tous les workers sont morts renvoie ce message. Surveillez les jobs pending.
  • La file d’attente est une table PostgreSQL, dépilée par SELECT … FOR UPDATE SKIP LOCKED. Pas de courtier, pas de Redis. Un seul job à la fois par projet : le nombre de workers utile, c’est le nombre de projets simultanés.
  • Les téléchargements ne passent pas par Django. Django signe une URL valable 600 secondes et rend la main à nginx par X-Accel-Redirect. Le déclencheur de ce chemin est la présence de X-Forwarded-For, et en DEBUG=0 il n’y a aucun repli.
  • Le défaut QFIELDCLOUD_WORKER_QFIELDCLOUD_URL=http://app:8000/api/v1/ casse toute instance en DEBUG=0. Le correctif : NGINX_ALLOW_INTERNAL_HTTP=1, un alias réseau portant QFIELDCLOUD_HOST sur nginx, et l’URL du worker qui passe par ce nom. Nginx n’accepte qu’un seul en-tête Host.
  • Le conteneur QGIS est plafonné à 1 Go, par un réglage django-constance modifiable dans l’administration — pas dans le .env. C’est le chiffre de dimensionnement à multiplier par le nombre de workers.
  • TMP_DIRECTORY est une contrainte d’identité entre l’hôte et le wrapper, pas un simple cache. Le chemin calculé dans le conteneur est interprété par le démon Docker comme un chemin de l’hôte.
  • Le versionnement des fichiers est applicatif : une clé S3 par version, projects/<uuid>/files/<nom>/v<horodatage>-<empreinte>. Le versioning de bucket n’est qu’une ceinture de plus.
  • L’inscription est ouverte par défaut. QFIELDCLOUD_ACCOUNT_ADAPTER vaut AccountAdapterSignUpOpen dans le dépôt : sur une instance publiée, n’importe qui peut se créer un compte. AccountAdapterSignUpClosed bascule en mode sur invitation, sans rien casser d’autre.
  • Le profil standalone publie quatre services sur 0.0.0.0 — stockage objet, console S3, interface et SMTP du piège à courriels, WebDAV — et SMTP4DEV_SMTP_PORT vaut 25, ce qui entre en conflit avec le moindre agent de transport local.
  • 847 Mo de grilles PROJ écrits, et des journaux qu’aucune limite ne borne sur une instance vide. Les grilles occupent le disque tout de suite ; les journaux, eux, ne le remplissent qu’avec le temps — les 22,5 Go annoncés ne couvrent que cinq services sur douze. Ni l’un ni l’autre n’est dans la documentation, et les deux se remboursent sur votre disque.
  • Le correctif d’une panne de conception a tenu dans deux variables et huit lignes d’override, sans toucher une ligne du dépôt upstream. C’était le pari de l’article précédent ; il est tenu.
Alternative : Si vous n’avez pas besoin de comprendre tout cela pour démarrer, les deux tutoriels cités en introduction restent d’excellents points de départ, et plus rapides que cet article. Revenez-y le jour où un job échoue sans message : c’est à ce moment-là que la carte devient utile.

Il reste à sortir cette instance du bac à sable : un vrai certificat, un vrai relais de courriel, un stockage objet qu’on n’a pas honte de laisser tourner, et des sauvegardes dont on a testé la restauration. C’est le programme du cinquième et dernier article.

Documentation self-hosted de QFieldCloud

Image de couverture : Scott Blake sur Unsplash.

QFieldCloud auto-hébergé - Cet article fait partie d'une série.
Partie 4: Cet article

Articles connexes