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 migrationsLe 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.
v26.26).
Table des matières #
- Ce qui rend une instance difficile à maintenir
- La règle unique
- Étape 1 — suivre
release, pasmaster - Étape 2 — votre dépôt, et le clone dedans
- Étape 3 — le
.env, dérivé et vérifié - Le profil
standalone, service par service - Étape 4 — un seul fichier Compose à vous
- Étape 5 — garder les volumes nommés
- Premier démarrage
- La routine de mise à jour
- Le service disparu :
geodb - Ce que la méthode ne vous épargne pas
- Conclusion
Ce qui rend une instance difficile à maintenir #
| 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 #
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 #
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 | Où | 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.
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/ # upstreamDeux 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 ensuitemkdir -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.
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.
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 masterDeux 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.27Le -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.
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.mdgit 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.
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 \
configcontext: /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 prisLa 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.
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_FILEaccepte un chemin relatif :../docker-compose.override.ymldésigne bien votre override depuissrc/, y compris quand la variable est lue dans le../.envpassé à--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’appelleraitsrc— conteneurs, réseau, images QGIS — etworker_wrapperne 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 :
- Étape 2 — un
git clonedirect dans/opt/docker/qfieldcloud, au lieu degit init+git submodule add. - Étape 3 — dans
COMPOSE_FILE, votre override s’écrit sans../: il est à la racine du clone. - Étape 4 et suivantes — pas de
Makefile, pas de--env-file ../.env:docker composese lance directement depuis le clone. Partout où l’article écritmake up, lisezdocker compose up -d --build. - Mise à jour —
git checkout vXX.YYdans le clone (voie 1) ougit mergedepuis le distantupstream(voie 2), sans commit d’enregistrement de version : c’est l’aliasqfcou la branchedeployqui tient l’historique.
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/ |
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 -5La 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.
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 #
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"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 où 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.
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.
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.
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_PATHIl 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.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
#
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 mkcertTout 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.
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 |
.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.toml — garage 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-appRestent trois pièges, dans l’ordre où ils se présentent.
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.
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 surapp— 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.
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.
docker compose up -d --remove-orphans, sans quoi les conteneurs devenus orphelins continuent de tourner.
Étape 4 — un seul fichier Compose à vous #
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ôtLe 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.yml — sans 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é |
…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:
- donotstartTrois 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 | sortdocker-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é :
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-localUn volume nommé reste par ailleurs parfaitement accessible quand il le faut :
docker volume inspect qfieldcloud_postgres_data --format '{{ .Mountpoint }}'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 #
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/
{"database":"ok","storage":"ok", …} en HTTP 200. Vérifiez ensuite dc ps — rustfs 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.
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.
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
#
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-orphanspour éliminer le conteneurgeodbdevenu orphelin ; - retirer toutes les variables
GEODB_*du.env; - la table
core_geodbest supprimée définitivement par la migration — si vous l’utilisiez encore, ne passez pas cette version sans avoir sauvegardé.
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=requirePG_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;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.
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 :
- Vous modifiez le projet dans QGIS — nouvelle couche, symbologie, formulaire de saisie.
- Vous le poussez avec QFieldSync (
Push), qui envoie le.qgset les fichiers associés. - QFieldCloud enregistre le dépôt et marque le projet comme à retraiter.
- Au prochain pull d’un appareil, un job de packaging fabrique un paquet neuf.
- 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.
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.
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 #
| É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 (core → project) |
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.
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 #
Ce que vous retenez :
- L’upstream publie une branche
releaseet des tagsvAA.NN— environ un par semaine. Déployez un tag, pasmaster. - Lancez toujours
docker composedepuissrc/: les fichiers Compose utilisent des chemins relatifs, et${PWD}pour le volume des CA. LeMakefilede 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,.envetdocker-compose.override.yml. - Un point de plus, c’est à l’upstream :
…override.prod.ymlest 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.YYpuisgit commit, et votregit logdevient 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_FILElivré par défaut est un profil de développement : remplacez l’overridelocalparprod, et ajoutez le vôtre en dernier — avec son../, puisqu’il vit un cran au-dessus desrc/. - 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.pyvalide votre.envcontre les fichiers Compose — à lancer après chaque mise à jour. - Le socle ne définit que l’application :
db,rustfs,webdavetsmtp4devviennent du profilstandaloneet se remplacent un par un —rustfsetcreatebucketsrestant solidaires. -
webdavest un backend de stockage de production, pas un outil de test ;smtp4dev, lui, se retire dès la mise en service. -
geodba disparu env25.27: votre propre PostGIS, atteinte par un secretPG_SERVICE_, le remplace avantageusement. -
git -C src statusvide = zéro dette. C’est le seul indicateur à surveiller.
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.
Image de couverture : Scott Blake sur Unsplash.