Aller au contenu
  1. Posts/

QFieldCloud auto-hébergé [E03] : un déploiement qui ne crée pas de dette

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

Introduction
#

Les deux articles précédents racontent une histoire en deux temps. D’abord une instance QFieldCloud auto-hébergée, montée à ma main, avec ses quinze services d’alors et ses partis pris. Puis, dix mois plus tard, la remise à niveau de cette instance sur l’upstream : quatre migrations imbriquées, une soirée de travail, et trois pièges d’exploitation.

Épisode 1 — anatomie d'une stack standalone Épisode 2 — une mise à jour, quatre migrations

Le plus instructif, dans cette migration, n’est pas ce qui a cassé. C’est de constater que presque tout le travail venait de ma propre mise en page du déploiement, pas du logiciel. Un docker-compose.yml recopié hors du dépôt, des volumes nommés transformés en montages liés, un master suivi sans référence stable : trois décisions prises en une heure, remboursées pendant une soirée.

Vous allez voir le plan que je suivrais aujourd’hui pour déployer une instance QFieldCloud à partir du dépôt upstream : une méthode en trois couches, où tout ce qui vous appartient tient dans deux fichiers, et où la mise à jour redevient une opération de routine.


Info : L’article vise un déploiement auto-hébergé sur un seul hôte Docker (VM, LXC ou machine physique), du type de ceux qu’on trouve dans une collectivité, un bureau d’études ou un homelab. Les états observés du dépôt datent de fin août 2026 (v26.26).

Table des matières
#

Ce qui rend une instance difficile à maintenir
#

Quatre décisions de départ, et le coût qu’elles finissent par facturer.
Décision de départ Ce qu’elle coûte à la mise à jour
Recopier docker-compose.yml hors du dépôt, avec des contextes de build réécrits Il faut rejouer à la main chaque évolution upstream du fichier : nouveaux services, nouvelles variables, ancres YAML
Remplacer les volumes nommés par des montages liés ./volumes/… Les conteneurs tournent sous des UID qui ne sont pas les vôtres : collectstatic refuse d’écrire, rustfs refuse de démarrer
Déployer depuis master Aucun point de référence : impossible de dire « je suis en retard de N versions », ni de revenir à un état connu
Écrire le .env à la main, sans relire .env.example Des variables obligatoires apparaissent au fil des versions ; leur absence fait planter le conteneur app au démarrage

Aucune de ces décisions n’est absurde prise isolément. Ensemble, elles transforment une mise à jour en projet.

La règle unique
#

Ne jamais modifier un fichier dont l’upstream est propriétaire.

Tout le reste en découle. Concrètement, votre déploiement se range en trois couches, et vous n’écrivez que dans la deuxième.

graph TB
    subgraph "Couche 1 · Upstream — jamais éditée"
        U1["docker-compose.yml"]
        U2["docker-compose.override.*.yml"]
        U3["docker-app / docker-qgis
docker-nginx / conf"] end subgraph "Couche 2 · Instance — la vôtre" I1[".env"] I2["docker-compose.override.yml"] I3["conf/ca, conf/nginx/certs
(contenu local)"] end subgraph "Couche 3 · Données — volumes nommés" D1[("postgres_data")] D2[("rustfs_data")] D3[("static / media
transformation_grids")] end U1 --> I2 I1 --> I2 I2 --> D1 I2 --> D2 I2 --> D3 style U1 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style U2 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style U3 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style I1 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style I2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style I3 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style D1 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style D2 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style D3 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000

La bonne nouvelle : l’upstream a prévu cette séparation. Son .gitignore ignore déjà .env* — à l’exception de .env.example — et, surtout, docker-compose.override.yml. Ce nom de fichier vous est explicitement réservé.

Concrètement, ce que vous créez
#

Quatre choses seulement. Deux vous appartiennent et vivent dans votre dépôt ; deux sont des dossiers de travail, à l’intérieur du clone upstream.

L’article retient une structure à deux niveaux : votre dépôt de déploiement, qui contient le clone upstream comme sous-module dans src/. La section suivante explique pourquoi, et compare aux autres options.

# Quoi Obligatoire Comment
1 .env racine de votre dépôt oui cp src/.env.example .env, puis édition (étape 3)
2 docker-compose.override.yml racine de votre dépôt oui vous l’écrivez (étape 4) — même réduit à services: {} au départ
3 src/conf/certbot/ et src/conf/nginx/config.d/ dans le clone oui mkdir -p avant le premier up
4 src/conf/ca/custom_ca.crt dans le clone non votre bundle de CA interne, si vous en avez un

Pourquoi cette répartition ? Les éléments 1 et 2 sont votre configuration : ils méritent un historique, donc ils vivent dans votre dépôt. Les éléments 3 et 4 sont montés par des chemins que l’upstream écrit en relatif — ./conf/nginx/config.d, ${PWD:-.}/conf/ca — et doivent donc rester dans le clone. Ils y sont couverts par le .gitignore upstream : ils n’apparaîtront jamais dans un git status, et un git checkout d’une nouvelle version ne les touchera pas.

Attention : Corollaire à ne pas manquer : le contenu de src/conf/ — vos certificats, votre CA interne, vos directives nginx supplémentaires — n’est versionné nulle part, ni dans votre dépôt, ni chez l’upstream. Il relève de votre sauvegarde, au même titre que le .env.

Voici l’arborescence obtenue, une fois l’instance déployée :

/opt/docker/qfieldcloud-stack/              ← VOTRE dépôt : c'est TOUTE votre stack
├── .git/                                   # votre historique de déploiement
├── .gitmodules                             ← épingle src/ sur le dépôt upstream
├── .gitignore                              ← à vous : une ligne, `.env`
├── Makefile                                ← 2 lignes, pour ne jamais oublier le `cd src`
├── .env                                    ← 1 · VOUS (secrets, domaine, ports, COMPOSE_FILE)
├── .env.template                           ← copie du .env sans les secrets, elle commitée
├── docker-compose.override.yml             ← 2 · VOUS (sans qualificatif — voir étape 4)
└── src/                                    ← SOUS-MODULE : le clone upstream, JAMAIS édité
    ├── .git                                # fichier, pas dossier : pointeur du sous-module
    ├── .gitignore                          # upstream — ignore .env*, l'override et conf/
    ├── .env.example                        # upstream — votre documentation de référence
    ├── docker-compose.yml                  # upstream — socle, JAMAIS édité
    ├── docker-compose.override.local.yml       # upstream — profil « développement »
    ├── docker-compose.override.standalone.yml  # upstream — profil « tout embarqué »
    ├── docker-compose.override.prod.yml        # upstream — profil « exploitation »
    ├── docker-compose.override.staging.yml     # upstream — profil « préproduction »
    ├── docker-compose.override.test.yml        # upstream — profil « tests »
    ├── docker-app/  docker-qgis/           # upstream — contextes de build
    ├── docker-nginx/  docker-createbuckets/
    ├── scripts/check_envvars.py            # upstream — votre outil de contrôle
    └── conf/
        ├── ca/
        │   ├── README.md                   # upstream
        │   └── custom_ca.crt               ← 4 · VOUS (facultatif)
        ├── certbot/                        ← 3 · VOUS (mkdir) — rempli par certbot ensuite
        └── nginx/
            ├── certs/                      # rempli par mkcert au 1er démarrage
            ├── config.d/                   ← 3 · VOUS (mkdir) — directives nginx en plus
            └── dhparams/                   # upstream

Deux fichiers à vous, un Makefile de deux lignes, et un sous-module. C’est tout le déploiement.

Ce que vous ne créez pas, et qu’il ne faut donc pas préparer à la main :

Élément Qui le fabrique
conf/nginx/certs/*.pem le service mkcert, au premier démarrage
conf/certbot/conf/… le service certbot, à la première émission de certificat
Les neuf volumes de données le démon Docker, au premier up
staticfiles/, mediafiles/ collectstatic et l’application, dans les volumes

Les trois commandes qui matérialisent tout cela, juste après le clone :

cd /opt/docker/qfieldcloud-stack
mkdir -p src/conf/certbot src/conf/nginx/config.d       # 3 — dans le clone
cp src/.env.example .env                                # 1 — à éditer ensuite
printf 'services: {}\n' > docker-compose.override.yml   # 2 — à étoffer ensuite
Attention : Le mkdir -p n’est pas cosmétique. src/conf/certbot/ et src/conf/nginx/config.d/ sont montés par nginx et certbot mais n’existent pas dans le dépôt — leur contenu est ignoré par Git. Si vous lancez docker compose up sans les avoir créés, c’est le démon Docker qui les crée, en root:root : vous ne pourrez plus y déposer un fichier sans passer par sudo.

Versionner votre override
#

La solution la plus immédiate consiste à tout mettre dans le clone upstream : .env et docker-compose.override.yml à sa racine, et rien d’autre. Ça marche, et beaucoup de déploiements s’en contentent. Mais cette approche — appelons-la le dossier unique — a un défaut, et un seul : votre docker-compose.override.yml n’a pas d’historique.

Et il n’en a pas parce qu’il ne peut pas en avoir : le seul dépôt Git présent dans ce dossier est celui de l’upstream. Vous n’avez aucun droit de poussée dessus, et son .gitignore écarte justement ce fichier — c’est même à cette condition qu’il vous est réservé. Le commiter là serait absurde : vous saliriez le git status que toute la méthode cherche à garder propre, pour un commit que vous ne pourriez jamais pousser.

Conséquence concrète : vous ne saurez pas quand ni pourquoi vous avez fixé le nombre de workers, ni pourquoi nginx ne publie plus que sur la boucle locale, et le fichier ne survit que par votre sauvegarde. D’où les trois voies ci-dessous, qui ont toutes le même objet : lui donner un dépôt à lui.

Erreur : git clean -xdf supprime les fichiers ignorés — donc votre .env et votre docker-compose.override.yml. C’est la seule commande Git réellement dangereuse dans ce dossier. Ne la tapez jamais ici.

Pour une instance unique, sauvegarder ces deux fichiers avec le dump de base suffit. Si vous voulez un vrai historique, trois voies, de la plus légère à la plus expressive.

Info : Autant l’annoncer tout de suite : c’est la voie 3, le sous-module, que suit la suite de l’article. Les étapes 2 à 5, le premier démarrage et la routine de mise à jour sont tous écrits pour elle. Les deux autres voies restent parfaitement valables — un encadré, après le tableau comparatif, indique ce qu’elles changent.

Voie 1 — un dépôt nu, sans second dossier. C’est la recette classique des dépôts de dotfiles : un dépôt Git sans copie de travail, qui prend le clone pour arbre de travail. Un seul dossier sur le disque, et vos fichiers versionnés quand même.

git init --bare /opt/docker/qfieldcloud-config.git
alias qfc='git --git-dir=/opt/docker/qfieldcloud-config.git --work-tree=/opt/docker/qfieldcloud'

qfc config status.showUntrackedFiles no          # sinon il liste tout l'arbre upstream
qfc add -f docker-compose.override.yml .env.template   # -f : le .gitignore upstream s'applique ici aussi
qfc commit -m "Instance webgis : 2 workers, nginx sur la boucle locale"
qfc remote add origin https://forgejo.exemple.fr/vous/qfieldcloud-config.git
qfc push -u origin master

Deux dépôts Git superposés sur le même dossier, chacun ignorant l’autre. git status continue de parler de l’upstream, qfc status de votre configuration.

Voie 2 — un fork. Si vous voulez un dépôt qui décrive toute la stack et reste rattaché à l’upstream, forkez-le (sur GitHub, ou en miroir sur votre Forgejo) et travaillez sur une branche de déploiement. Votre override devient alors un fichier suivi, avec son historique, ses messages de commit et ses relectures.

git clone https://forgejo.exemple.fr/vous/qfieldcloud.git /opt/docker/qfieldcloud
cd /opt/docker/qfieldcloud
git remote add upstream https://github.com/opengisch/qfieldcloud.git
git fetch upstream --tags

git checkout -b deploy v26.26
git add -f docker-compose.override.yml .env.template
git commit -m "Instance webgis : 2 workers, nginx sur la boucle locale"
git push -u origin deploy

# Mise à jour : une fusion au lieu d'un checkout
git fetch upstream --tags
git merge v26.27

Le -f est nécessaire une seule fois : docker-compose.override.yml est dans le .gitignore upstream, mais un fichier déjà suivi échappe au .gitignore. Les fusions se passent sans conflit tant que vous ne touchez qu’à ce fichier — il n’existe pas en face.

Attention : Avec un fork, le contrôle change de nature. git checkout refusait une modification d’un fichier upstream ; git merge la fusionne en silence. L’invariant devient « aucune modification non commitée », et il faut relire le résultat de la fusion. Ne versionnez par ailleurs jamais le .env : il contient SECRET_KEY, SALT_KEY et tous les mots de passe. Seul un .env.template, valeurs sensibles remplacées par change_me, a sa place dans un dépôt.

Voie 3 — l’upstream en submodule. Votre dépôt décrit la stack, et déclare opengisch/qfieldcloud comme sous-module épinglé sur un tag. C’est la variante la plus expressive des trois : la version déployée n’est plus une note dans un README, c’est un commit de votre dépôt.

/opt/docker/qfieldcloud-stack/     ← VOTRE dépôt, celui que vous clonez
├── .gitmodules                    # déclare src/ → opengisch/qfieldcloud
├── src/                           ← le sous-module, épinglé sur v26.26
├── docker-compose.override.yml    ← à vous
├── .env.template
└── README.md
git init /opt/docker/qfieldcloud-stack && cd /opt/docker/qfieldcloud-stack
git submodule add -b release https://github.com/opengisch/qfieldcloud.git src
git -C src checkout v26.26
git add .gitmodules src docker-compose.override.yml .env.template
git commit -m "Instance webgis sur QFieldCloud v26.26"

# Mise à jour : on déplace le sous-module, et on l'enregistre
git -C src fetch --tags && git -C src checkout v26.27
git commit -am "Passage à v26.27"

Le .env reste à côté du sous-module, dans votre dépôt, et ignoré — jamais commité. Le git log de votre dépôt devient l’historique du déploiement : « v26.26 », « 2 workers », « v26.27 ». C’est exactement ce qu’on veut d’un dépôt d’infrastructure.

Attention : Une seule règle, mais elle est non négociable : docker compose se lance depuis src/, jamais depuis la racine de votre dépôt. Les contextes de build se résolvent par rapport au fichier Compose et suivent donc partout, mais ${PWD:-.}/conf/ca suit le répertoire courant. Lancé depuis la racine, Compose ne produit aucune erreur — il monte simplement le volume des CA sur stack/conf/ca au lieu de stack/src/conf/ca.

Vérifié sur une stack réelle, en lançant docker compose config depuis src/ avec l’override du niveau au-dessus :

cd src
docker compose --env-file ../.env \
  -f docker-compose.yml \
  -f docker-compose.override.standalone.yml \
  -f docker-compose.override.prod.yml \
  -f ../docker-compose.override.yml \
  config
context: /opt/docker/qfieldcloud-stack/src/docker-app      ← correct
device:  /opt/docker/qfieldcloud-stack/src/conf/ca         ← correct
scale:   2                                                 ← l'override du dessus est bien pris

La même commande lancée depuis la racine renvoie device: /opt/docker/qfieldcloud-stack/conf/ca — silencieusement faux. Le remède tient en quelques lignes de Makefile à la racine, pour que personne n’ait à y penser :

# La chaîne des fichiers vient du COMPOSE_FILE de votre .env (étape 3)
COMPOSE = cd src && docker compose --env-file ../.env

.PHONY: up config migrate logs

up:      ; $(COMPOSE) up -d --build
config:  ; $(COMPOSE) config -q
migrate: ; $(COMPOSE) exec app python manage.py migrate
logs:    ; $(COMPOSE) logs -f $(S)

La ligne .PHONY n’est pas de la décoration. Ces cibles portent des noms de tous les jours, et make cherche par défaut un fichier du même nom : le jour où un config ou un logs traîne à la racine — un dossier suffit —, make config répond make: 'config' is up to date et ne lance rien. Pas d’erreur, pas de validation non plus. .PHONY déclare que ces cibles ne produisent aucun fichier, et la recette s’exécute toujours.

Astuce : Ce que le Makefile ne fait pas, et c’est heureux : vous déplacer. make exécute chaque recette dans un sous-shell, donc le cd src meurt avec la commande. Après un make up, votre terminal est toujours à la racine du dépôt, prêt pour le make down suivant. Le cd et le docker compose doivent en revanche rester sur la même ligne, reliés par && — d’où leur regroupement dans la variable COMPOSE. Sur deux lignes de recette, la seconde repartirait de la racine, et le ${PWD} du volume des CA retomberait dans le piège que ce Makefile est censé fermer.

Deux points vérifiés sur une stack réelle, qui font que cette enveloppe suffit :

  • COMPOSE_FILE accepte un chemin relatif : ../docker-compose.override.yml désigne bien votre override depuis src/, y compris quand la variable est lue dans le ../.env passé à --env-file ;
  • COMPOSE_PROJECT_NAME=qfieldcloud, déjà présent dans le .env.example, empêche Compose de nommer le projet d’après le répertoire courant. Sans lui, tout s’appellerait src — conteneurs, réseau, images QGIS — et worker_wrapper ne retrouverait pas ses petits.

Alternative : La suite de l’article suit la voie 3. Si vous préférez la voie 1 ou la voie 2, quatre choses changent, et pas une de plus :

  1. Étape 2 — un git clone direct dans /opt/docker/qfieldcloud, au lieu de git init + git submodule add.
  2. Étape 3 — dans COMPOSE_FILE, votre override s’écrit sans ../ : il est à la racine du clone.
  3. Étape 4 et suivantes — pas de Makefile, pas de --env-file ../.env : docker compose se lance directement depuis le clone. Partout où l’article écrit make up, lisez docker compose up -d --build.
  4. Mise à jourgit checkout vXX.YY dans le clone (voie 1) ou git merge depuis le distant upstream (voie 2), sans commit d’enregistrement de version : c’est l’alias qfc ou la branche deploy qui tient l’historique.

Info : Cette structure ressemble à celle qui m’a coûté une soirée — un dépôt upstream dans src/, une stack au-dessus. La différence est décisive : à l’époque, j’avais recopié et modifié docker-compose.yml au niveau du dessus. Ici, src/ est intact et n’est jamais édité ; seul l’override du dessus vous appartient. Ce n’est pas le sous-dossier qui crée la dette, c’est la copie.

graph TB
    A["Un seul dossier
(défaut)"] --> A1["Le clone EST la stack.
Aucune cérémonie, mais
pas d'historique."] B["Voie 1 · dépôt nu"] --> B1["Un seul dossier sur le disque,
override versionné à côté."] C["Voie 2 · fork + branche deploy"] --> C1["Un seul dépôt contient tout,
y compris l'historique upstream.
Mise à jour = fusion."] D["Voie 3 · submodule"] --> D1["Dépôt léger, version déployée
enregistrée comme commit.
Compose se lance depuis src/."] style A fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style B fill:#e1f5fe,stroke:#0277bd,stroke-width:2px,color:#000 style C fill:#e1f5fe,stroke:#0277bd,stroke-width:2px,color:#000 style D fill:#e1f5fe,stroke:#0277bd,stroke-width:2px,color:#000

Voie 1 · dépôt nu Voie 2 · fork Voie 3 · submodule
Dossiers sur le disque 1 1 1 (+ src/)
Taille de votre dépôt minuscule tout l’historique upstream minuscule
Version déployée à noter à la main la branche deploy un commit
Mise à jour git checkout git merge git -C src checkout + commit
Cérémonie un alias aucune submodule update --init
Où lancer Compose dans le clone dans le clone dans src/
Astuce : Le test de bonne santé de votre déploiement tient en une commande : git -C src status doit rester vide. Un fichier upstream modifié, c’est une dette que vous rembourserez à la prochaine mise à jour.

Étape 1 — suivre release, pas master
#

C’est le point que j’ignorais, et de loin le plus rentable.

Le dépôt opengisch/qfieldcloud ne publie pas seulement une branche master de développement. Il maintient une branche release, dans laquelle master est fusionné, et tague chaque publication selon un versionnement calendaire vAA.NN : v26.1, v26.2, … v26.26.

git ls-remote --tags --sort=-v:refname https://github.com/opengisch/qfieldcloud.git 'v*' | head -5

La cadence sur l’année 2026 :

Tag Date Tag Date
v26.26 2026-08-28 v26.10 2026-05-12
v26.24 2026-08-18 v26.6 2026-04-02
v26.20 2026-07-06 v26.3 2026-02-25
v26.15 2026-06-02 v26.1 2026-02-05

Vingt-six publications en un peu moins de sept mois, soit une par semaine environ. Au moment de la rédaction, release et master pointent d’ailleurs sur un contenu identique : suivre les tags ne vous met pas en retard, il vous donne simplement un nom pour l’état que vous exploitez.

Attention : Les tags vAA.NN vivent sur la branche release, pas sur master. Un git describe --tags lancé sur un clone de master renvoie quelque chose comme v0.29.0-1844-gd7d8221 — un ancien tag et un compteur de commits, illisible. Clonez release si vous voulez que votre version ait un nom.

Concrètement : vous déployez un tag, vous notez lequel, et une mise à jour devient « passer de v26.24 à v26.26 » — avec un git log v26.24..v26.26 lisible pour savoir ce qui change.

Étape 2 — votre dépôt, et le clone dedans
#

Le dépôt upstream n’est pas une bibliothèque de fichiers à recopier : c’est le répertoire de travail. Il devient le sous-module src/ du dépôt qui, lui, vous appartient.
sudo install -d -o 1000 -g 1000 /opt/docker
cd /opt/docker

# 1. Votre dépôt de déploiement
git init qfieldcloud-stack && cd qfieldcloud-stack
printf '.env\n' > .gitignore          # le .env ne sera JAMAIS commité

# 2. L'upstream, en sous-module, épinglé sur un tag
git submodule add -b release https://github.com/opengisch/qfieldcloud.git src
git -C src checkout v26.26

# 3. Le premier commit : la version déployée est désormais un fait enregistré
git add .gitmodules .gitignore src
git commit -m "Instance webgis sur QFieldCloud v26.26"
Astuce : git -C src checkout v26.26 place le sous-module en HEAD détachée, et c’est voulu : un sous-module épingle un commit, pas une branche. C’est le commit de votre dépôt qui porte le nom de version, dans son message. Vous n’avez donc pas de branche deploy à entretenir ici.

Le point important n’est pas se trouve le clone, mais d’où vous lancez docker compose : toujours depuis src/. Les fichiers Compose upstream sont truffés de chemins relatifs que déplacer le point d’exécution casse :

Référence upstream Ce qu’elle attend
build: context: ./docker-app app et worker_wrapper construits depuis le dépôt
build: context: ./docker-qgis Images qgis3 et qgis4
- ./conf/nginx/certs/:/etc/nginx/certs/:ro Certificats du frontal
device: ${PWD:-.}/conf/ca Volume custom_ca_certificates, monté depuis le répertoire courant

Cette dernière ligne est la plus tranchante : le volume des autorités de certification personnalisées est défini avec ${PWD}. Lancer docker compose depuis un autre répertoire ne produit pas une erreur — il produit un volume monté au mauvais endroit.

C’est toute la raison d’être du Makefile posé à l’étape précédente : son cd src garantit que personne, un soir de mise à jour, ne lancera docker compose depuis la racine du dépôt. Le clone vit en sous-dossier, et c’est parfaitement sain — ce qui est interdit, c’est de recopier ses fichiers un cran au-dessus.

Erreur : Lancé depuis la racine de votre dépôt, Compose ne proteste pas : il monte simplement le volume des CA sur qfieldcloud-stack/conf/ca au lieu de qfieldcloud-stack/src/conf/ca. Aucun message, un volume vide, et des certificats internes qui ne sont jamais reconnus. Passez par make.

Info : Ce volume custom_ca_certificates sert à injecter un bundle de CA maison dans app, worker_wrapper et les conteneurs QGIS éphémères. Si vous n’en avez pas besoin, laissez src/conf/ca/ tel quel : le dossier ne contient qu’un README.md et le montage reste inoffensif.

Étape 3 — le .env, dérivé et vérifié
#

Le .env.example du dépôt fait plus de 600 lignes, chaque variable étant précédée de son commentaire, de ses valeurs admises et de son défaut. C’est la documentation de référence de la configuration — pas seulement un gabarit.

cp src/.env.example .env      # à la racine de VOTRE dépôt, pas dans src/

Puis les modifications minimales pour une instance réelle :

Clé Valeur Pourquoi
QFIELDCLOUD_HOST votre domaine Sans schéma ni port ni slash final
DJANGO_ALLOWED_HOSTS votre domaine Doit contenir QFIELDCLOUD_HOST
ENVIRONMENT production Défaut : development
DEBUG 0 Défaut : 1 impératif
SECRET_KEY / SALT_KEY générés pwgen -sn 128 — jamais les valeurs d’exemple
POSTGRES_PASSWORD, OBJECT_STORAGE_ROOT_*, WEBDAV_PASSWORD générés Idem
LETSENCRYPT_EMAIL / LETSENCRYPT_STAGING votre adresse / 0 1 par défaut, pour ne pas cogner les quotas pendant les essais
COMPOSE_FILE voir ci-dessous Le défaut est un profil de développement

Ce dernier point mérite qu’on s’y arrête. Le défaut livré est :

COMPOSE_FILE=docker-compose.yml:docker-compose.override.local.yml:docker-compose.override.standalone.yml

(les chemins y sont relatifs à src/, puisque c’est de là que Compose est lancé)

Or docker-compose.override.local.yml remplace gunicorn par manage.py runserver sous debugpy, monte le code source en direct pour le rechargement à chaud et ouvre des ports de débogage. Ce n’est pas un profil d’exploitation. Pour une instance réelle, on lui substitue l’override prod, qui monte les volumes static_volume et media_volume dans nginx — indispensable pour que le frontal serve les fichiers statiques — et on ajoute son propre override en dernier :

COMPOSE_FILE=docker-compose.yml:docker-compose.override.standalone.yml:docker-compose.override.prod.yml:../docker-compose.override.yml
#            └─ socle ────────┘ └─ deux profils upstream retenus ─────────────────────────────────────┘ └─ le vôtre, un cran au-dessus ─┘

Le ../ du dernier n’est pas une coquette : votre override vit à la racine de votre dépôt, Compose est lancé depuis src/. Vérifié sur une stack réelle — la variable accepte parfaitement un chemin relatif, y compris lue depuis le ../.env passé à --env-file.

Attention : Docker Compose ne charge automatiquement docker-compose.override.yml que si COMPOSE_FILE n’est pas défini. Ici il l’est : votre override doit être listé explicitement, et en dernier — l’ordre détermine qui gagne en cas de conflit. De toute façon, le chargement automatique ne l’aurait pas trouvé : il cherche dans le répertoire courant, c’est-à-dire src/.

Vérifier le .env — l’outil est dans le dépôt
#

Le dépôt embarque scripts/check_envvars.py, qui compare un fichier d’environnement à l’ensemble des fichiers Compose et signale les variables manquantes ou orphelines. Il est prévu pour la CI, mais il accepte n’importe quel fichier :

python3 src/scripts/check_envvars.py .env --docker-compose-dir src \
  --ignored-varnames DEBUG_APP_DEBUGPY_PORT DEBUG_WORKER_WRAPPER_DEBUGPY_PORT \
                     DEBUG_QGIS_DEBUGPY_PORT DEBUG_QGIS_WORKER_HOST_PATH

Il se lance depuis la racine de votre dépôt : le .env y est, les fichiers Compose sont dans src/.

Sortie type sur un .env en retard :

Envvar "MINIO_ROOT_USER" is defined in the .env file, but not found in any docker-compose file.
Envvar "MINIO_ROOT_PASSWORD" is defined in the .env file, but not found in any docker-compose file.
Some envvars are not passed properly, 2 problem(s) found.
Astuce : Lancez ce script après chaque mise à jour, avant même de reconstruire. C’est le moyen le plus rapide de repérer une variable devenue obligatoire — celles que Django lit via os.environ[...] et dont l’absence fait échouer le démarrage de app, sans message explicite côté Compose.

Le profil standalone, service par service
#

Quinze services au premier démarrage, et aucune documentation qui dise lesquels sont le logiciel et lesquels sont des béquilles de développement. Voici le tri.
Info : Quinze services déclarés, mais onze conteneurs seulement restent en vie après le premier up. qgis3 et qgis4 ne servent qu’à construire les images — leur commande est un echo QGIS3 built qui rend la main aussitôt, les vrais conteneurs QGIS étant créés à la volée par worker_wrapper. mirror_transformation_grids et createbuckets sont des tâches à passage unique. Ne vous alarmez donc pas de voir quatre services Exited dans docker compose ps.

C’est le point qui m’a le plus désorienté au premier déploiement, et celui que la documentation upstream n’explicite nulle part : le socle et le profil standalone ne jouent pas du tout le même rôle.

Le fichier docker-compose.yml — le socle, celui qu’on ne discute pas — ne définit que l’application :

app  nginx  worker_wrapper  qgis3  qgis4  memcached  ofelia
mirror_transformation_grids  certbot  mkcert

Tout le reste — la base de données, le stockage objet, le serveur de courriel, le WebDAV — vient de docker-compose.override.standalone.yml, et uniquement de là :

graph TB
    subgraph "docker-compose.yml — le logiciel"
        A1["app
(Django)"] A2["worker_wrapper
+ qgis3 / qgis4"] A3["nginx"] A4["memcached"] end subgraph "override.standalone.yml — les dépendances embarquées" B1[("db
PostGIS")] B2["rustfs
S3"] B3["createbuckets"] B4["webdav"] B5["smtp4dev"] end A1 -.->|"POSTGRES_*"| B1 A1 -.->|"STORAGES"| B2 A1 -.->|"STORAGES"| B4 A1 -.->|"EMAIL_*"| B5 B3 -->|"crée le bucket"| B2 style A1 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style A2 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style A3 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style A4 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style B1 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style B2 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style B3 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style B4 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style B5 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000

Les flèches en pointillés sont importantes : le socle ne connaît ces services que par des variables d’environnement. Le seul depends_on de docker-compose.yml est ofelia → app ; l’override local, celui du développement, en ajoute un vers db, mais vous ne l’utilisez pas. Autrement dit, retirer un service du profil standalone ne casse aucun graphe de démarrage : il faut seulement pointer la variable correspondante ailleurs.

Une exception, et elle est interne au profil : createbuckets déclare depends_on: rustfs avec condition: service_healthy. Les deux ne se séparent donc pas.

Erreur : Neutraliser rustfs sans neutraliser createbuckets casse la stack avant même son démarrage : service "createbuckets" depends on undefined service "rustfs": invalid compose project. Ni docker compose config -q ni up ne passent. Retirez toujours les deux ensemble.

Le tableau que j’aurais voulu lire
#

Service Ce que c’est réellement Accès par défaut Nécessaire ? Remplaçable par
db PostgreSQL/PostGIS de l’application (utilisateurs, projets, jobs) — rien à voir avec vos données métier <hôte>:5433 (HOST_POSTGRES_PORT) — toutes interfaces Oui, sous une forme ou une autre Tout PostgreSQL externe via POSTGRES_HOST/PORT/USER/PASSWORD/SSLMODE
rustfs Serveur S3 embarqué, en version 1.0.0-beta.11 API :8009, console web :8010 Un stockage S3 ou WebDAV est obligatoire MinIO, Garage, Ceph RGW, un S3 hébergé — voir plus bas
createbuckets Conteneur éphémère qui crée le bucket, active le versioning et ouvre le préfixe users Non, si vous créez le bucket vous-même Trois commandes à la main
webdav Un backend de stockage à part entière, pas un outil de test http://<hôte>:8020, Basic auth Non Un Nextcloud existant, tout serveur WebDAV
smtp4dev Faux serveur SMTP qui capture les courriels dans une interface web http://<hôte>:8012 (SMTP :25, IMAP :143) Non — et à retirer en production Votre relais SMTP, ou un Mailpit existant
Attention : Le .env.example intitule cette section « Standalone settings — development only ». À prendre au sérieux pour smtp4dev, à nuancer pour les autres : db et rustfs sont parfaitement viables en interne, webdav est un vrai backend de production. La mention couvre le profil pris en bloc, pas chaque service pris isolément.

rustfs — le remplacer par un S3 existant, Garage par exemple
#

C’est prévu par la conception, et c’est même le cas le plus propre : le stockage n’est câblé nulle part en dur. app, worker_wrapper et les conteneurs QGIS éphémères lisent une seule variable, STORAGES — un STORAGES Django, au format JSON, dont le .env.example documente trois exemples.

Pointer une instance Garage déjà déployée tient donc dans le .env :

STORAGES='{
  "default": {
    "BACKEND": "qfieldcloud.filestorage.backend.QfcS3Boto3Storage",
    "OPTIONS": {
      "access_key": "GK...",
      "secret_key": "...",
      "bucket_name": "qfieldcloud",
      "region_name": "garage",
      "endpoint_url": "https://s3.mondomaine.interne"
    }
  }
}'

La valeur de region_name doit être celle du s3_region de votre garage.tomlgarage par défaut. Côté Garage, le bucket et la clé se créent en trois commandes :

garage bucket create qfieldcloud
garage key create qfieldcloud-app
garage bucket allow --read --write --owner qfieldcloud --key qfieldcloud-app

Restent trois pièges, dans l’ordre où ils se présentent.

Erreur : createbuckets échouera sur Garage. Le script docker-createbuckets/createbuckets.py enchaîne quatre appels en check=True : rc alias set, rc mb --ignore-existing (bucket), rc anonymous set public …/users, puis rc version enable. Les deux premiers passent — c’est sur les deux derniers que ça casse. Or Garage n’implémente ni les bucket policies / ACL, ni le versioning de bucket (GetBucketVersioning y est un stub qui répond toujours « non activé »). Créez le bucket à la main, et neutralisez le service.

Ce versioning absent inquiète moins qu’il n’y paraît, à une condition. Le système de fichiers actuel de QFieldCloud gère les versions applicativement : une table FileVersion en base, et un chemin d’objet du type projects/<uuid>/files/DCIM/photo.jpg/v20260317162354-512bd29b. Le commentaire du script est explicite : le versioning S3 y est activé « pour le soft-delete au niveau applicatif », pas comme dépendance. En revanche, du code hérité subsiste — la classe S3ObjectVersion de core/utils.py, qui itère bucket.object_versions — pour les projets non encore migrés vers ce nouveau système.

Attention : Traduction opérationnelle : Garage est un bon choix pour une instance créée en neuf, pas pour une instance ancienne dont tous les projets ne sont pas passés au nouveau système de fichiers. Vérifiez ce point avant de migrer un stockage existant.

Troisième piège, le plus sournois : endpoint_url doit être joignable depuis nginx, pas seulement depuis Django. Le téléchargement d’un fichier ne transite pas par l’application : filestorage/view_helpers.py génère une URL présignée valable 600 secondes et renvoie un en-tête X-Accel-Redirect: /storage-download/. C’est nginx qui va ensuite chercher l’objet et le diffuse au client.

sequenceDiagram
    participant C as Client QField
    participant N as nginx
    participant A as app (Django)
    participant S as Garage (S3)
    C->>N: GET /api/v1/files/...
    N->>A: requête proxifiée
    A->>A: contrôle des droits
    A-->>N: 200 + X-Accel-Redirect + URL présignée (600 s)
    N->>S: GET sur l'URL présignée
    S-->>N: flux de l'objet
    N-->>C: flux relayé, sans mise en cache disque

C’est le sens exact de la note du .env.example : « The endpoint_url must be a URL reachable from within docker and the host ». Deux conséquences concrètes :

  • le nom DNS de Garage doit résoudre à l’intérieur du réseau Docker de la stack ;
  • si Garage est derrière une autorité de certification interne, montez-la dans le volume custom_ca_certificates — déjà prévu sur app — faute de quoi nginx refusera la connexion TLS.

Bonne nouvelle : Garage gère SigV4, les URL présignées et le multipart upload. Le chemin fonctionne. Quant à l’accès anonyme au préfixe users, il n’est pas bloquant : les avatars passent eux aussi par une URL présignée.

webdav — un backend de stockage, pas un outil de test
#

C’est la confusion la plus coûteuse du profil standalone. L’image bytemark/webdav:2.4 n’est qu’un serveur WebDAV jetable, mais le backend qu’elle sert à exercer, qfieldcloud.filestorage.backend.QfcWebDavStorage, est destiné à la production. Le .env.example en donne d’ailleurs un exemple Nextcloud :

{
  "webdav_nextcloud": {
    "BACKEND": "qfieldcloud.filestorage.backend.QfcWebDavStorage",
    "OPTIONS": {
      "webdav_url": "https://mon.nextcloud/remote.php/dav/files/UTILISATEUR",
      "public_url": "https://mon.nextcloud/public.php/webdav",
      "basic_auth": "JETON_DE_PARTAGE:"
    }
  }
}

Le serveur embarqué s’atteint sur http://<hôte>:8020 (WEBDAV_PUBLIC_PORT), en authentification Basic avec WEBDAV_USERNAME / WEBDAV_PASSWORD — les valeurs d’exemple étant qfc_webdav_user / qfc_webdav_pwd. Il se monte tel quel dans un explorateur de fichiers.

À quoi sert-il vraiment ? À ranger les pièces jointes — les photos de terrain, typiquement — ailleurs que dans le stockage objet, dans une arborescence lisible par un humain, via STORAGES_PROJECT_DEFAULT_ATTACHMENTS_STORAGE. Et c’est le seul backend qui autorise à désactiver leur versionnement, ce que l’upstream note noir sur blanc : STORAGE_PROJECT_DEFAULT_ATTACHMENTS_VERSIONED=0 n’est possible qu’avec un stockage de pièces jointes de type WebDAV.

Astuce : Si vous restez tout-S3 — le cas le plus courant — supprimez purement et simplement webdav de votre stack. Si vous avez déjà un Nextcloud, c’est au contraire l’occasion d’y déverser les photos sans conserver n versions de chacune.

smtp4dev — le seul qui soit vraiment du développement
#

rnwood/smtp4dev:v3 est un serveur SMTP qui accepte tout et n’envoie rien : il capture les courriels et les affiche dans une interface web, avec en prime un accès IMAP.

Usage Adresse
Interface web — lire les courriels capturés http://<hôte>:8012 (SMTP4DEV_WEB_PORT)
SMTP — ce que Django vise via EMAIL_HOST=smtp4dev port 25 (SMTP4DEV_SMTP_PORT)
IMAP — pour un client de messagerie port 143 (SMTP4DEV_IMAP_PORT)

Son utilité est réelle pendant l’installation : tant que ACCOUNT_EMAIL_VERIFICATION=mandatory, c’est le seul endroit où lire le lien de validation d’adresse ou de réinitialisation de mot de passe. En production, il se remplace par votre relais réel :

EMAIL_HOST=smtp.mondomaine.fr
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=qfieldcloud@mondomaine.fr
EMAIL_HOST_PASSWORD=...
DEFAULT_FROM_EMAIL="qfieldcloud@mondomaine.fr"

Si vous tenez à garder un piège à courriels mais mieux outillé, un Mailpit déjà déployé fait exactement le même travail : il suffit de pointer EMAIL_HOST dessus.

Neutraliser sans supprimer
#

Deux gestes, selon l’ampleur.

Un service à la fois — vous gardez le profil standalone mais écartez un composant. Dans votre docker-compose.override.yml :

services:
  smtp4dev:
    profiles: [donotstart]
  rustfs:
    profiles: [donotstart]
  createbuckets:
    profiles: [donotstart]
  webdav:
    profiles: [donotstart]

Le profil entier — vous avez déjà un PostgreSQL, un S3 et un relais SMTP. Retirez simplement docker-compose.override.standalone.yml du COMPOSE_FILE :

COMPOSE_FILE=docker-compose.yml:docker-compose.override.prod.yml:docker-compose.override.yml

…puis renseignez dans le fichier .env les variables POSTGRES_*, STORAGES et EMAIL_* pour qu’elles pointent vers vos services. Comme aucun service du socle ne dépend d’eux, rien d’autre n’est à toucher.

Info : Après avoir retiré des services d’une stack déjà démarrée, relancez-la avec docker compose up -d --remove-orphans, sans quoi les conteneurs devenus orphelins continuent de tourner.

Étape 4 — un seul fichier Compose à vous
#

Tout ce que l’upstream ne prévoit pas tient dans docker-compose.override.yml — à la racine de votre dépôt, un cran au-dessus de src/.

Ne pas le confondre avec les cinq de l’upstream
#

À la racine du clone, cinq fichiers portent déjà un nom en docker-compose.override.*. Ils ne jouent pas le même rôle que le vôtre, et Git tranche :

src/docker-compose.yml                       SUIVI par l'upstream
src/docker-compose.override.local.yml        SUIVI par l'upstream
src/docker-compose.override.prod.yml         SUIVI par l'upstream
src/docker-compose.override.standalone.yml   SUIVI par l'upstream
src/docker-compose.override.staging.yml      SUIVI par l'upstream
src/docker-compose.override.test.yml         SUIVI par l'upstream
src/docker-compose.override.yml              ignoré (.gitignore upstream, ligne 9)
docker-compose.override.yml                  ← LE VÔTRE, suivi par VOTRE dépôt

Le vôtre n’est pas dans src/ : il est au-dessus, et c’est ce qui lui donne un historique. La ligne 9 du .gitignore upstream reste utile — elle garantit que si vous en posiez un dans src/ par erreur, il n’apparaîtrait pas comme une modification du sous-module.

Les cinq de l’upstream Le vôtre
Nom docker-compose.override.<qualificatif>.yml docker-compose.override.ymlsans qualificatif
Statut Git suivis, versionnés par OPENGIS.ch ignoré (.gitignore)
Qui l’écrit l’upstream vous
À quoi ça sert des profils : développement, exploitation, tout-embarqué, préproduction, tests votre site : ports, ressources, particularités de l’hôte
Comment on s’en sert on en choisit dans COMPOSE_FILE on l’ajoute toujours, et en dernier
Au git checkout v26.27 mis à jour par l’upstream jamais touché
Astuce : Le moyen mnémotechnique tient en une ligne — un point de plus, c’est à l’upstream. …override.prod.yml porte un qualificatif, il ne vous appartient pas ; …override.yml n’en a pas, il est à vous. Ce n’est pas une convention maison : docker-compose.override.yml est le nom réservé par Docker Compose à la surcharge locale, celui qu’il charge tout seul quand COMPOSE_FILE n’est pas défini. L’upstream ne l’utilise jamais pour ses propres profils, précisément pour vous le laisser.

Les profils que vous ne retenez pas dans COMPOSE_FILE restent sur le disque sans aucun effet : Compose ne lit que les fichiers de la chaîne.

Ce que vous y mettez
#

Vous n’y remplacez rien : vous ajoutez des fragments qui viennent se fusionner par-dessus les définitions upstream.

# /opt/docker/qfieldcloud-stack/docker-compose.override.yml
# le SEUL fichier Compose qui vous appartient — et il est versionné
services:

  nginx:
    # Publier uniquement sur la boucle locale : un Traefik externe est en frontal
    ports: !override
      - "127.0.0.1:8480:80"
      - "127.0.0.1:8443:443"

  db:
    # Sauvegarde plus simple si la base n'est pas exposée à tout le LAN
    ports: !override
      - "127.0.0.1:5433:5432"
    # Réglages PostgreSQL propres à l'hôte, et arrêt de la journalisation
    # de TOUTES les requêtes activée par l'override standalone
    command:
      - postgres
      - -c
      - shared_buffers=512MB
      - -c
      - log_statement=ddl

  worker_wrapper:
    # Deux workers en parallèle sur cet hôte
    scale: 2

  smtp4dev:
    # Non désiré en production : `donotstart` n'est démarré par aucun profil
    # Voir « Le profil standalone, service par service » pour les autres candidats
    profiles:
      - donotstart

Trois mécanismes font tout le travail :

Mécanisme Effet
Fusion Compose Les clés déclarées ici s’ajoutent à celles de l’upstream, le reste est conservé
!override Sur une liste fusionnée (ports, volumes…), remplace au lieu de concaténer — sinon les deux publications de ports coexistent. Inutile sur command, que Compose remplace déjà
profiles: [donotstart] Neutralise un service sans le supprimer ; le profil est déjà utilisé en interne par l’upstream
graph LR
    A["docker-compose.yml"] --> M{{"fusion Compose"}}
    B["override.standalone.yml"] --> M
    C["override.prod.yml"] --> M
    D["docker-compose.override.yml
(à vous, en dernier)"] --> M M --> R["configuration effective
docker compose config"] style A fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style B fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style C fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style D fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style R fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000

Avant tout démarrage, la configuration fusionnée se lit et se valide — par le Makefile, pour ne pas oublier le cd src :

make config                                    # docker compose config -q
cd src && docker compose --env-file ../.env config --services | sort
Info : Une trentaine de lignes lisibles d’un coup d’œil, à comparer avec un docker-compose.yml de 250 lignes recopié et divergent. C’est aussi le seul fichier qui mérite un historique : voir Versionner votre override.

Étape 5 — garder les volumes nommés
#

C’est le renoncement le moins intuitif, et celui qui rapporte le plus.

L’upstream déclare neuf volumes nommés : static_volume, media_volume, transformation_grids, certbot_www, custom_ca_certificates, plus postgres_data, rustfs_data, smtp4dev_data et webdav_data côté standalone. La tentation est de les convertir en montages liés ./volumes/…, pour « voir les fichiers ».

Le prix de cette visibilité :

Erreur : rustfs : [FATAL] Server runtime failed: Io error: Permission denied (os error 13) — l’image tourne sous un utilisateur non-root et n’ajuste pas les droits du dossier monté. Même famille de symptôme pour collectstatic, dont le conteneur app tourne en UID 10001 alors que le dossier hôte appartient à l’UID 1000.

Avec un volume nommé, Docker crée le point de montage aux droits attendus par l’image : le problème n’existe pas. Et l’argument de la sauvegarde ne tient pas — on ne sauvegarde ni une base ni un stockage objet en copiant des fichiers sous les pieds du service :

# Base : dump logique, cohérent, restaurable sur une autre version majeure
# (depuis src/, comme toute commande Compose)
docker compose --env-file ../.env exec -T db pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB" \
  > "../backup/qfc-$(date +%F).dump"

# Stockage objet : copie via l'API S3, sans toucher au format sur disque
mc alias set qfc "http://127.0.0.1:${OBJECT_STORAGE_API_PORT}" "$OBJECT_STORAGE_ROOT_USER" "$OBJECT_STORAGE_ROOT_PASSWORD"
mc mirror --overwrite --preserve qfc/qfieldcloud-local /srv/backup/qfieldcloud-local

Un volume nommé reste par ailleurs parfaitement accessible quand il le faut :

docker volume inspect qfieldcloud_postgres_data --format '{{ .Mountpoint }}'
Astuce : static_volume, media_volume, certbot_www et transformation_grids sont reconstructibles — respectivement par collectstatic, par le stockage objet, par certbot et par le service mirror_transformation_grids. Votre plan de sauvegarde ne porte donc que sur deux choses : la base et le stockage objet. Plus le .env, qui contient les clés de chiffrement.

Premier démarrage
#

Les images QGIS sont lourdes : on les construit séparément, en surveillant l’espace disque.
graph TD
    S1["1 · docker compose build qgis3
puis qgis4"] --> S2 S2["2 · docker compose up -d --build"] --> S3 S3["3 · manage.py migrate"] --> S4 S4["4 · manage.py collectstatic"] --> S5 S5["5 · createsuperuser + compilemessages"] --> S6 S6["6 · GET /api/v1/status/"] style S1 fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000 style S2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style S3 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style S4 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style S5 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style S6 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000

Tout se lance depuis src/. Le Makefile couvre les commandes courantes ; pour les autres, on descend d’un cran :

cd /opt/docker/qfieldcloud-stack/src
alias dc='docker compose --env-file ../.env'   # le temps de la session

# 1. Les deux images QGIS, une par une (~10 min chacune, 2,4 à 3,3 Go)
dc build qgis3
docker builder prune -f && df -h /
dc build qgis4
docker builder prune -f && df -h /

# 2. Le reste de la stack          (ou : make up, depuis la racine)
dc up -d --build

# 3. Schéma de base                (ou : make migrate)
dc exec app python manage.py migrate

# 4. Fichiers statiques (volume nommé : aucun problème de droits)
dc run --rm app python manage.py collectstatic --noinput

# 5. Compte d'administration et traductions
dc run --rm app python manage.py createsuperuser
# `exec`, pas `run --rm` : les .mo s'écrivent hors volume, un conteneur jetable les emporte
dc exec --user root app python manage.py compilemessages

# 6. Contrôle
curl -sk https://votre-domaine/api/v1/status/

Succès : {"database":"ok","storage":"ok", …} en HTTP 200. Vérifiez ensuite dc psrustfs en healthy, app et worker_wrapper à restarts=0 — puis, seul test qui compte vraiment, un pull puis un push depuis un client QField : il doit déclencher un job traité dans un conteneur qgis3 éphémère.

Attention : Prévoyez large sur le disque. Les deux images QGIS pèsent environ 5,7 Go à elles seules, avant les caches de build. Sous 40 Go de libre, les constructions deviennent inconfortables.

La routine de mise à jour
#

C’est le retour sur investissement. Avec les cinq étapes précédentes respectées, une montée de version tient en une séquence courte et répétable.

flowchart TD
    A["Sauvegarde : dump base + miroir objets
+ snapshot de l'hôte"] --> B["git -C src fetch --tags"] B --> C["git -C src log vACTUEL..vCIBLE
lire ce qui change"] C --> D["git -C src checkout vCIBLE"] D --> E{"git -C src status
vide ?"} E -->|non| F["Un fichier upstream a été modifié :
déplacer le changement dans l'override"] F --> E E -->|oui| G["diff .env.example : nouvelles variables ?"] G --> H["check_envvars.py .env"] H --> I["make config"] I --> J["make up"] J --> K["migrate + collectstatic"] K --> L["GET /api/v1/status/"] L --> M["git commit -am 'Passage à vCIBLE'"] style M fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style E fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000 style F fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000 style L fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
cd /opt/docker/qfieldcloud-stack

# Ce qui change entre la version déployée et la cible
git -C src fetch --tags origin release
git -C src log --oneline v26.26..v26.27
git -C src diff v26.26..v26.27 -- .env.example docker-compose.yml docker-compose.override.standalone.yml

# Bascule du sous-module, puis contrôle : le clone doit être propre
git -C src checkout v26.27
git -C src status --short          # doit être vide : aucun fichier upstream modifié
git status --short                 # doit montrer : modified: src (new commits)

# Contrôles avant reconstruction
python3 src/scripts/check_envvars.py .env --docker-compose-dir src --ignored-varnames …
make config

# Application
make up
make migrate
cd src && docker compose --env-file ../.env run --rm app python manage.py collectstatic --noinput
cd ..

# Enregistrement : la version déployée entre dans VOTRE historique
git commit -am "Passage à v26.27"

Le point clé est cette ligne git -C src diff … -- .env.example docker-compose.yml : au lieu de comparer votre fichier bricolé à celui de l’upstream, vous lisez le diff de l’upstream avec lui-même. C’est exactement l’information dont vous avez besoin, et rien d’autre.

Le second point clé est le dernier git commit. C’est lui qui distingue cette voie des autres : la version déployée n’est pas une note dans un carnet, c’est une ligne de votre git log. Six mois plus tard, git log --oneline vous dit quelle version tournait à quelle date, et git show vous rend l’override tel qu’il était alors.

Astuce : git checkout refuse de basculer quand un fichier modifié localement diffère aussi entre les deux versions — il vous prévient au lieu d’écraser. Attention toutefois : si le fichier que vous avez touché n’a pas bougé entre v26.26 et v26.27, Git bascule sans rien dire et conserve votre modification. C’est pourquoi les deux git status de la séquence comptent autant que la bascule : git -C src status doit être vide, et git status ne doit montrer que src (new commits) — la marque d’un sous-module déplacé volontairement.

Le service disparu : geodb
#

Si vous revenez à QFieldCloud après une longue absence, un conteneur manque à l’appel. Ce n’est pas une régression : c’est un abandon assumé, et son remplaçant est meilleur.

Ce que geodb faisait, concrètement
#

Pendant quatre ans, le docker-compose.yml embarquait un service geodb : une seconde base PostGIS, distincte de celle de l’application, dans laquelle QFieldCloud créait dynamiquement une base par utilisateur. Un modèle Django Geodb (user, username, dbname, hostname, port), un drapeau is_geodb_enabled sur le compte, et une page dans l’administration Django pour créer tout ça d’un clic — l’URL était /admin/core/geodb/add/. La réponse à la question « était-ce administrable depuis l’interface d’admin ? » est donc oui.

Le point qui n’apparaît nulle part dans la documentation, et qui change tout : cette base ne contenait rien de QFieldCloud. Ni les utilisateurs, ni les projets, ni les jobs — tout cela vit dans db, et y vit toujours. geodb hébergeait vos couches métier : les parcelles, les réseaux, les relevés. Les données que vous éditez sur le terrain.

Le service rendu était donc : « QFieldCloud vous fournit aussi l’hébergement PostGIS ». L’administrateur créait une base, remettait à l’utilisateur un couple hôte/base/identifiants, l’utilisateur pointait les couches de son projet QGIS dessus, et le serveur les lisait au moment de fabriquer le paquet QField. C’était une offre commerciale en germe, pas une brique technique nécessaire au fonctionnement.

Pourquoi il a disparu
#

L’upstream l’explique sans détour dans la pull request qui l’a retiré, #1379 « feat: kill the geodb once and forever », fusionnée le 15 octobre 2025 :

Geodb was a core functionality on pre-production days of QFC, where users can create their own postgis database dynamically and this could be sold as additional product along QFieldCloud. This never took off due to complexities of managing a database for someone.

Soit, en français : « Geodb était une fonctionnalité centrale à l’époque de la pré-production de QFieldCloud : les utilisateurs pouvaient y créer dynamiquement leur propre base PostGIS, et l’idée était de la vendre comme produit additionnel aux côtés de QFieldCloud. Ça n’a jamais décollé, à cause de la complexité qu’il y a à administrer une base de données pour quelqu’un d’autre. »

Le retrait s’est fait en quatre temps, sur treize mois :

graph LR
    A["janv. 2021
Introduction
migration 0009"] --> B["sept. 2024
Retrait de
l'API status
PR #1011"] B --> C["nov. 2024
Retrait de
l'admin Django
PR #1068"] C --> D["oct. 2025
Suppression du modèle
et du service
PR #1379 — v25.27"] D --> E["oct. 2025
Nettoyage des
variables GEODB_*
PR #1394"] style A fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style B fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style C fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style D fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000 style E fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000

La suppression définitive est arrivée dans v25.27 (24 octobre 2025). Les notes de version de la pull request listent trois gestes d’exploitation :

  • redémarrer la stack avec --remove-orphans pour éliminer le conteneur geodb devenu orphelin ;
  • retirer toutes les variables GEODB_* du .env ;
  • la table core_geodb est supprimée définitivement par la migration — si vous l’utilisiez encore, ne passez pas cette version sans avoir sauvegardé.
Astuce : C’est exactement le genre d’écart que scripts/check_envvars.py détecte en une seconde après un git checkout : vos GEODB_* apparaîtront comme définies dans le .env mais absentes de tout fichier Compose.

Par quoi on le remplace
#

Par votre propre base PostGIS, celle que vous administrez déjà — et c’est très largement préférable. Aucune fonctionnalité n’est perdue : seul l’hébergement change de main.

Avant (geodb) Aujourd’hui
Qui crée la base QFieldCloud, depuis l’admin Django Vous, sur votre serveur PostGIS
Qui en a les clés l’administrateur QFieldCloud vous
Sauvegardes, extensions, droits subis à vous
Ce que voit l’utilisateur des identifiants remis à la main rien : le projet QGIS référence un service nommé

La liaison ne se fait plus par un service embarqué mais par un secret de type pg_service, mécanisme qui existe toujours et qui, lui, est pleinement pris en charge.

Le principe : votre projet QGIS référence ses couches via un service PostgreSQL nommé (service=ma_base), sans jamais contenir d’identifiants. La définition de ce service est stockée dans QFieldCloud sous forme de secret chiffré, puis injectée dans le conteneur QGIS au moment du traitement.

graph LR
    A["Projet QGIS
service=ma_base"] --> B["Secret PGSERVICE
(chiffré en base)"] B -->|"PGSERVICE_FILE_CONTENTS"| C["Conteneur qgis3
éphémère"] C -->|"écrit ~/.pg_service.conf"| D["QGIS résout le service"] D -->|"port 5432"| E[("Votre PostGIS
que vous administrez")] style A fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000 style B fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style C fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000 style D fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000 style E fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000

Concrètement, dans l’administration Django, vous créez un secret dont le nom commence par PG_SERVICE_ et dont la valeur est le contenu d’un fichier pg_service.conf — exactement une définition de service, la validation le vérifie :

[ma_base]
host=postgis.mondomaine.interne
port=5432
dbname=cadastre
user=qfieldcloud
password=...
sslmode=require
Attention : C’est ici que l’on se trompe, parce que deux noms sans rapport cohabitent. Le nom du secret (PG_SERVICE_CADASTRE) sert à QFieldCloud à retrouver l’entrée ; il doit commencer par PG_SERVICE_ et s’écrire en capitales. Le nom entre crochets dans le fichier ([ma_base]) est celui que QGIS résout : c’est lui, et lui seul, qui doit correspondre au service=ma_base de vos couches. Les deux peuvent différer, et différer sans conséquence — tant qu’on ne les confond pas.

Ce secret se pose à trois niveaux, ce qui remplace très avantageusement l’ancien « une base par utilisateur » :

Portée Effet
Projet Tous les traitements de ce projet utilisent cette connexion
Organisation Valeur par défaut pour tous les projets de l’organisation
Assigné à un utilisateur Cet utilisateur se connecte avec ses propres identifiants

La valeur est chiffrée en base (EncryptedTextField), et au lancement d’un job, docker-qgis/entrypoint.py la reçoit dans la variable PGSERVICE_FILE_CONTENTS et écrit ~/.pg_service.conf dans le conteneur éphémère.

Le cycle complet, du bureau au terrain
#

Une question revient forcément : votre PostGIS métier, est-ce un autre serveur que celui qui a servi à monter le projet QGIS ? Non — c’est le même. Ce qui change, ce n’est pas le serveur, c’est qui s’y connecte :

Qui se connecte Depuis où Avec quel fichier
Vous, dans QGIS votre poste votre ~/.pg_service.conf
Le worker QFieldCloud un conteneur QGIS éphémère celui écrit à partir du secret

Les deux portent le même nom de service — c’est ce qui permet au projet, qui ne contient que service=ma_base, de fonctionner des deux côtés. Leur contenu, lui, peut légitimement différer : votre poste passe peut-être par un nom public et un VPN, le serveur par une adresse interne et un compte dédié. Le nom de service est le contrat ; le reste est local à chaque machine.

sequenceDiagram
    participant Q as QGIS (votre poste)
    participant C as QFieldCloud
    participant W as Conteneur QGIS éphémère
    participant P as Votre PostGIS
    participant F as QField (terrain)
    Q->>C: push du projet (service=ma_base, sans identifiants)
    C->>W: job de packaging + PGSERVICE_FILE_CONTENTS
    W->>P: SELECT sur les couches
    P-->>W: données
    W-->>C: GeoPackage prêt
    C-->>F: pull du paquet
    F->>C: push des deltas après la saisie
    C->>W: job apply_deltas
    W->>P: INSERT / UPDATE / DELETE

La dernière flèche est celle qu’on oublie, et elle a une conséquence directe sur les droits.

Les droits à accorder, précisément
#

QFieldCloud ne fait pas que lire vos couches pour fabriquer le paquet : au retour du terrain, le job apply_deltas réécrit dans la source. Le code ouvre les couches en édition et appelle addFeature, changeAttributeValue, changeGeometry et deleteFeature sur leur fournisseur de données — donc directement dans PostGIS.

Un compte en lecture seule ne fait donc pas échouer le packaging : tout fonctionne, jusqu’au premier retour de terrain. C’est une panne qui arrive une semaine après la mise en service, et qu’on ne relie pas spontanément aux droits.

L’utilisateur déclaré dans le pg_service.conf a besoin de quatre choses, et de rien de plus :

-- 1. se connecter et voir le schéma
GRANT CONNECT ON DATABASE cadastre TO qfieldcloud;
GRANT USAGE   ON SCHEMA metier      TO qfieldcloud;

-- 2. lire ET écrire les tables publiées dans le projet
GRANT SELECT, INSERT, UPDATE, DELETE
  ON ALL TABLES IN SCHEMA metier TO qfieldcloud;

-- 3. les séquences des clés primaires : sans ça, tout INSERT échoue
--    (« permission denied for sequence … »)
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA metier TO qfieldcloud;

-- 4. pour les tables créées plus tard
ALTER DEFAULT PRIVILEGES IN SCHEMA metier
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO qfieldcloud;
ALTER DEFAULT PRIVILEGES IN SCHEMA metier
  GRANT USAGE, SELECT ON SEQUENCES TO qfieldcloud;
Astuce : Aucun droit de définition n’est nécessaire : le worker ne crée, ne modifie et ne supprime aucune table — vérification faite, il n’exécute pas la moindre instruction DDL. Votre compte QFieldCloud n’a donc pas à être propriétaire des tables, ni membre d’un rôle qui l’est. C’est exactement ce que l’ancien geodb ne permettait pas : il fallait un compte capable de créer des bases, ce qui le rendait impossible à héberger sur un PostgreSQL managé — c’est d’ailleurs le sujet d’une des toutes premières remontées d’utilisateurs sur ce service.

Hors ligne ou en direct ? Les deux existent
#

Tout ce qui précède décrit le mode hors ligne, et c’est bien celui que QFieldCloud applique par défaut à une couche PostGIS. Mais ce n’est pas le seul : libqfieldsync, la bibliothèque qui fait le packaging, propose deux actions pour une couche vectorielle en ligne.

Action Ce que reçoit QField Qui écrit dans PostGIS
Offline editing défaut une copie GeoPackage le serveur, en appliquant les deltas
Directly access data source la couche PostGIS telle quelle le terminal, directement

Le choix se fait couche par couche, dans QFieldSync. Il change complètement la topologie :

graph TB
    subgraph "Offline editing (défaut)"
        A1["QField"] -->|deltas| A2["QFieldCloud"]
        A2 -->|INSERT/UPDATE/DELETE| A3[("PostGIS")]
    end
    subgraph "Directly access data source"
        B1["QField"] -->|connexion PostgreSQL| B3[("PostGIS")]
    end
    style A1 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style A2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
    style A3 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
    style B1 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style B3 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000

En accès direct, le serveur QFieldCloud sort du chemin de données : c’est le téléphone ou la tablette qui ouvre une connexion PostgreSQL. Cela suppose que l’appareil joigne votre base depuis le terrain — donc une exposition sur Internet ou un VPN mobile — et que les identifiants soient disponibles côté appareil. Les droits d’écriture décrits ci-dessus s’appliquent alors au compte utilisé par l’appareil, pas à celui du secret.

Astuce : Pour du relevé de terrain, le hors ligne reste le bon choix par défaut : il survit à l’absence de réseau, et il n’expose pas votre base. L’accès direct se justifie pour une couche de consultation — un fond de plan métier, un référentiel volumineux qu’on ne veut pas dupliquer sur chaque appareil — ou quand plusieurs équipes doivent voir les saisies des autres en temps réel. Rien n’interdit de mélanger les deux dans un même projet, couche par couche.

Quand le paquet est-il refabriqué ?
#

C’est la troisième question, et la réponse tient en une phrase : à chaque fois que quelqu’un tire le projet, dès lors qu’il contient une couche en ligne.

Le déroulé complet d’une modification faite au bureau :

  1. Vous modifiez le projet dans QGIS — nouvelle couche, symbologie, formulaire de saisie.
  2. Vous le poussez avec QFieldSync (Push), qui envoie le .qgs et les fichiers associés.
  3. QFieldCloud enregistre le dépôt et marque le projet comme à retraiter.
  4. Au prochain pull d’un appareil, un job de packaging fabrique un paquet neuf.
  5. QField télécharge la nouvelle version.

Le point non évident est à l’étape 4. Pour un projet dont toutes les couches sont des fichiers, QFieldCloud sait comparer deux horodatages — dernière modification des données, dernier packaging — et ne refabrique rien si rien n’a bougé. Pour un projet branché sur PostGIS, il ne peut pas savoir si la base a changé sous ses pieds : le code le dit franchement en commentaire, « nous ne pouvons pas savoir s’il y a des modifications, donc mieux vaut supposer qu’il y en a ». Le paquet est donc systématiquement reconstruit.

Info : Conséquence à connaître pour dimensionner l’hôte : un projet PostGIS déclenche un conteneur QGIS éphémère à chaque pull, même si personne n’a rien touché. Avec dix agents qui synchronisent en début de journée, ce sont dix packagings. C’est l’argument qui justifie le scale: 2 sur worker_wrapper de l’étape 4 — et une raison de plus de surveiller la durée des jobs dans l’administration Django.
Attention : Les conteneurs QGIS sont créés à la volée sur le réseau Docker de la stack. Votre base doit être joignable depuis cet hôte Docker, règle de pare-feu comprise. C’est le seul vrai travail d’infrastructure que l’abandon de geodb vous transfère — en échange de quoi vous gardez la main sur les sauvegardes, les droits et les extensions.

Ce que la méthode ne vous épargne pas
#

Il faut être honnête : ce plan supprime la dette d’exploitation, pas les migrations de fond.
Événement Pourquoi la méthode n’y suffit pas
Saut de version majeure de PostgreSQL Les fichiers de données ne redémarrent pas sur la nouvelle image : dump/restore obligatoire, quelle que soit la mise en page
Remplacement d’un composant (MinIO → rustfs) Formats de stockage différents : il faut recopier chaque objet
Scission d’une application Django (coreproject) Le worker reste inopérant jusqu’à l’application des migrations — c’est le vrai facteur d’indisponibilité
Variables devenues obligatoires check_envvars.py les signale, mais c’est à vous de choisir la valeur

Ces quatre-là étaient au menu de ma migration, et ils y seraient restés même avec un déploiement irréprochable. La différence, c’est qu’ils auraient été le seul travail à faire — et qu’ils seraient arrivés un par un, chaque semaine ou presque, au lieu de tous ensemble après dix mois de retard.

Architecture : La fréquence est le vrai levier de maintenabilité. Une instance mise à jour tous les mois absorbe les ruptures une par une, documentées dans un git log court. La même instance mise à jour une fois par an les affronte toutes en même temps, sans savoir laquelle a cassé quoi.

Conclusion
#

Déployer QFieldCloud à partir de l’upstream ne demande pas de recopier son dépôt : il demande de s’y installer, et de ne rien y toucher.

Ce que vous retenez :

  • L’upstream publie une branche release et des tags vAA.NN — environ un par semaine. Déployez un tag, pas master.
  • Lancez toujours docker compose depuis src/ : les fichiers Compose utilisent des chemins relatifs, et ${PWD} pour le volume des CA. Le Makefile de deux lignes existe pour que vous n’ayez jamais à y penser.
  • Votre dépôt contient l’upstream, pas l’inverse : le clone est le sous-module src/, jamais édité. Votre personnalisation tient dans deux fichiers au-dessus de lui, .env et docker-compose.override.yml.
  • Un point de plus, c’est à l’upstream : …override.prod.yml est un profil du dépôt, …override.yml — sans qualificatif — est le vôtre.
  • La version déployée n’est pas une note dans un carnet : c’est un commit. git -C src checkout vXX.YY puis git commit, et votre git log devient l’historique du déploiement.
  • Deux autres voies conviennent aussi — dépôt nu et fork. Ce n’est pas le sous-dossier qui crée la dette, c’est la copie des fichiers upstream.
  • Le COMPOSE_FILE livré par défaut est un profil de développement : remplacez l’override local par prod, et ajoutez le vôtre en dernier — avec son ../, puisqu’il vit un cran au-dessus de src/.
  • Gardez les volumes nommés : ils suppriment toute la classe de problèmes de droits, et la sauvegarde se fait par dump logique et miroir S3, pas par copie de fichiers.
  • src/scripts/check_envvars.py valide votre .env contre les fichiers Compose — à lancer après chaque mise à jour.
  • Le socle ne définit que l’application : db, rustfs, webdav et smtp4dev viennent du profil standalone et se remplacent un par un — rustfs et createbuckets restant solidaires.
  • webdav est un backend de stockage de production, pas un outil de test ; smtp4dev, lui, se retire dès la mise en service.
  • geodb a disparu en v25.27 : votre propre PostGIS, atteinte par un secret PG_SERVICE_, le remplace avantageusement.
  • git -C src status vide = zéro dette. C’est le seul indicateur à surveiller.
Alternative : Si votre instance doit sortir du cadre standalone — base PostgreSQL managée, stockage objet externe, relais SMTP d’entreprise — n’écrivez pas un nouveau docker-compose.yml pour autant. Retirez docker-compose.override.standalone.yml du COMPOSE_FILE et pointez les variables POSTGRES_*, STORAGES et EMAIL_* vers vos services. La couche upstream reste intacte. Le détail service par service, avec les pièges, est dans Le profil standalone, service par service.

Il me reste à appliquer ce plan à ma propre instance : elle tourne aujourd’hui sur un master sans nom, avec un docker-compose.yml maintenu à la main et des montages liés partout. La bascule fera peut-être l’objet d’un quatrième 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 3: Cet article

Articles connexes