Introduction #
L’instance QFieldCloud qui sert mes projets de terrain QField tourne sur un conteneur LXC dédié, dans une stack Docker Compose classique. Elle avait pris beaucoup de retard sur l’upstream — le dépôt d’origine du projet, opengisch/QFieldCloud, dont mon déploiement n’est qu’une copie personnalisée. La procédure de mise à jour du dépôt tient pourtant en quelques lignes : mv src _src, re-cloner src, comparer l’ancien .env au nouveau .env.example, reconstruire.
L’architecture de cette instance — ses quinze services, ses partis pris — est détaillée dans l’article précédent ; on part ici de cet existant pour le réaligner sur le dépôt courant.
En pratique, ce diff a révélé bien autre chose qu’un renommage de variables. Entre la version déployée et le master actuel, quatre changements structurels se sont accumulés — et trois d’entre eux touchent des données, pas seulement de la configuration.
opengisch/QFieldCloud et la configuration de référence qu’il livre (docker-compose.yml, .env.example, Dockerfile, images). Mon instance en est un déploiement dérivé : elle utilise le code tel quel, mais maintient ses propres fichiers Compose et son .env. « Migrer vers l’upstream », c’est réaligner ce déploiement sur l’état courant de ce dépôt.
Table des matières #
- Contexte
- Vue d’ensemble : quatre migrations en une
- Les arbitrages
- Phase A — préparer les fichiers sans rien casser
- Vérification
- Phase B — les migrations de données
- Conclusion
Contexte #
Le point de départ :
| Élément | État |
|---|---|
| Hôte | LXC Proxmox dédié, disque root ZFS de 32 Go (22 Go libres) |
| Stack | /opt/docker/qfieldcloud-stack, docker-compose.yml + docker-compose.override.standalone.yml maintenus à la main |
| Ancien code | checkout upstream de fin 2025 (renommé _src, sans .git) |
| Nouveau code | master fraîchement cloné dans src (v0.29.0-1844-g…) |
| Services | tous à l’arrêt au moment de la migration |
Le dépôt QFieldCloud fournit un docker-compose.yml et des fichiers d’override, mais dès qu’on personnalise le déploiement (ports, chemins de volumes, reverse-proxy), on en maintient une copie locale qui n’est pas régénérée par le clone. Toute la difficulté est là : reporter les évolutions de l’upstream dans ces copies, sans casser les adaptations maison.
docker-compose.override.standalone.yml. Le nginx de la stack termine lui-même le TLS ; il n’y a pas de reverse-proxy externe devant.
watchdog-mux pendant un build Docker le jour même. Sur une machine à l’espace disque et aux I/O tendus, on ne relance pas un build en boucle : on vérifie d’abord l’état du pool ZFS.
Vue d’ensemble : quatre migrations en une #
Le diff entre l’ancien .env et le nouveau .env.example, complété par une lecture du nouveau docker-compose.yml et du code applicatif, met en évidence quatre chantiers distincts.
graph TB
subgraph "1 · Code — rebuild simple"
C1["app / nginx / worker_wrapper\nremis au niveau master"]
end
subgraph "2 · QGIS — 2 images à builder"
Q1["qgis"] --> Q2["qgis3 · QGIS 3.44"]
Q1 --> Q3["qgis4 · QGIS 4.2"]
end
subgraph "3 · Stockage objet — migration de données"
S1[("minio")] -->|"mirror des buckets"| S2[("rustfs 1.0.0-beta")]
end
subgraph "4 · Base — migration de données"
D1[("postgis:13-3.1")] -->|"pg_dump / restore"| D2[("postgis:17-3.5")]
end
G1["geodb (bundled)"] -->|"retiré upstream — base vide"| G2["supprimé"]
style S1 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
style S2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style D1 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
style D2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style G2 fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000
En détail :
- Code applicatif —
app,nginx,worker_wrapper: simple reconstruction sur le nouveau code. De nouvelles variables d’environnement apparaissent (CORS_ALLOWED_ORIGINS,DJANGO_USE_X_FORWARDED_HOST, timeouts nginx, tuning worker…), certaines obligatoires — l’app refuse de démarrer sans elles. - Images QGIS — le service
qgisunique est scindé enqgis3(Ubuntu noble, QGIS 3.44) etqgis4(Ubuntu resolute, QGIS 4.2), même contexte de build, arguments différents. Deux images lourdes à construire au lieu d’une. - Stockage objet — l’upstream remplace
minioparrustfsdans l’override standalone. rustfs est compatible S3 mais stocke sur disque dans son propre format : la bascule impose de recopier chaque objet de bucket à bucket. - Base de données — le défaut de
POSTGIS_IMAGE_VERSIONpasse de13-3.1-alpineà17-3.5-alpine. Cinq versions majeures de PostgreSQL et une version majeure de PostGIS : les fichiers de données existants ne redémarrent pas sur la nouvelle image sans dump/restore.
Et un retrait : le service geodb (la base PostGIS pour les données métier des utilisateurs) et toutes les variables GEODB_* ont disparu de l’upstream, y compris du code Django. Ici, le volume geodb sur disque faisait 1 Ko — aucune donnée à préserver.
Les arbitrages #
Certains choix appartiennent à l’exploitant, pas à l’upstream. Ceux retenus pour cette instance :
| Question | Décision | Raison |
|---|---|---|
| Stockage objet | Migrer vers rustfs | Suivre l’upstream ; MinIO conservé comme filet de secours jusqu’à validation |
| PostgreSQL | Migrer 13 → 17 | La base fait 82 Mo : le dump/restore est trivial à cette taille |
| Disposition des volumes | Garder les bind mounts ./volumes/… |
Supporté via l’override, sauvegardes et inspection plus simples, aucun gain fonctionnel à passer aux volumes nommés |
Service geodb |
Supprimer | Retiré du code upstream, volume local vide |
| Espace disque | Étendre le disque de l’hôte avant les builds | 22 Go libres ne suffisent pas pour deux images QGIS + caches |
rustfs est ici en version 1.0.0-beta.11. Acceptable pour un usage homelab et c’est ce que livre l’upstream, mais à garder en tête pour une instance de production.
Phase A — préparer les fichiers sans rien casser #
Toute cette phase se fait services arrêtés, sur des fichiers texte. Rien n’est destructif : chaque fichier modifié est d’abord sauvegardé, et la stack peut repartir sur l’ancienne configuration à tout moment.
1. Sauvegarder les fichiers maintenus à la main #
cd /opt/docker/qfieldcloud-stack
cp .env .env.bak-2026-09-01
cp docker-compose.yml docker-compose.yml.bak-2026-09-01
cp docker-compose.override.standalone.yml docker-compose.override.standalone.yml.bak-2026-09-01
mkdir -p migrate volumes/ca volumes/rustfs
volumes/ca accueillera un éventuel bundle de CA personnalisées (support ajouté par l’upstream) ; volumes/rustfs sera le volume de données du nouveau stockage objet.
2. Réécrire docker-compose.yml — deux écarts assumés
#
Le docker-compose.yml local est régénéré à partir de src/docker-compose.yml, avec exactement deux transformations conservées d’une mise à jour à l’autre :
graph LR
U["src/docker-compose.yml\n(upstream)"] --> T1["contextes de build\n./docker-* → ./src/docker-*"]
T1 --> T2["volumes nommés\n→ bind mounts ./volumes/…"]
T2 --> L["docker-compose.yml\n(local)"]
style U fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
style L fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
Les changements upstream repris tels quels : le split qgis → qgis3 + qgis4 (avec les arguments UBUNTU_VERSION / QGIS_REPOSITORY / QGIS_VERSION), l’option --no-control-socket de gunicorn, les cinq variables NGINX_* de timeout et de taille de corps, l’ancre YAML &django-env partagée entre app et worker_wrapper, et les nouvelles variables passées à l’app (COMPOSE_PROJECT_NAME, CORS_*, ACCOUNT_RATE_LIMITS, STORAGE*_ATTACHMENTS*, QFIELDCLOUD_WORKER_TIMEOUT_S…). Les blocs GEODB_* disparaissent de l’environment de app.
custom_ca_certificates dans app et worker_wrapper. Le worker_wrapper ne le propage aux conteneurs QGIS enfants que si /etc/ssl/custom_certs/custom_ca.crt existe. Sans CA personnalisée, un simple bind mount d’un dossier vide (./volumes/ca) suffit.
3. Réécrire l’override standalone #
docker-compose.override.standalone.yml reçoit les mêmes deltas (bind mounts, contexte ./src/docker-createbuckets) et deux changements de fond :
- le service
minioest remplacé parrustfs(imagerustfs/rustfs:1.0.0-beta.11, healthcheck, ports S3 et console) ; - le service
geodbet le blocvolumes:en pied de fichier sont supprimés.
Le service db reste sur postgis/postgis:${POSTGIS_IMAGE_VERSION} — la valeur pointera vers la 17 une fois le .env mis à jour.
4. Adapter le .env
#
Le principe : ne pas recopier .env.example, seulement en reprendre la structure. Les secrets existants (SECRET_KEY, SALT_KEY, mots de passe) ne sont jamais régénérés sur une instance en place.
| Action | Clés |
|---|---|
| Garder tel quel | tous les secrets, SMTP4DEV_SMTP_PORT, MINIO_* (le temps de la migration), MEMCACHED_PORT |
| Retirer | "QFC_IS_LEGACY": false dans le JSON STORAGES (déprécié, disparu upstream) |
| Changer | POSTGIS_IMAGE_VERSION → 17-3.5-alpine |
Ajouter (obligatoires — os.environ[...], l’app crashe sinon) |
DJANGO_USE_X_FORWARDED_HOST=0 · CORS_ALLOWED_ORIGINS=https://docs.qfield.org · CORS_ALLOW_CREDENTIALS=1 · QFIELDCLOUD_WORKER_TIMEOUT_S=600 · QFIELDCLOUD_WORKER_DEQUEUE_INTERVAL_S=5 |
| Ajouter (optionnelles, valeurs par défaut upstream) | ACCOUNT_RATE_LIMITS (JSON) · NGINX_CLIENT_MAX_BODY_SIZE=10g · NGINX_PROXY_{CONNECT,READ,SEND}_TIMEOUT · NGINX_ALLOW_INTERNAL_HTTP=0 · STORAGE_PROJECT_DEFAULT_ATTACHMENTS_VERSIONED=1 · bloc OBJECT_STORAGE_* (rustfs) |
| Renommer | QFIELDCLOUD_QGIS_IMAGE_NAME → QFIELDCLOUD_QGIS3_IMAGE_NAME + nouvelle QFIELDCLOUD_QGIS4_IMAGE_NAME |
| Fusionner | DEBUG_QGIS_LIBQFIELDSYNC_HOST_PATH + DEBUG_QGIS_QFIELDCLOUD_SDK_HOST_PATH → DEBUG_QGIS_WORKER_HOST_PATH |
| Supprimer | tout le bloc GEODB_* et HOST_GEODB_PORT |
STORAGES, donnez à rustfs les mêmes identifiants que l’ancien MinIO (OBJECT_STORAGE_ROOT_USER / OBJECT_STORAGE_ROOT_PASSWORD = anciens MINIO_ROOT_*). Les clés access_key / secret_key du bloc STORAGES restent alors valables sans modification.
DJANGO_USE_X_FORWARDED_HOST est mis à 0 : l’upstream le passe à 1, mais c’est pour les déploiements derrière un reverse-proxy externe. Ici, nginx est en frontal direct.
5. Valider #
docker compose config -q && docker compose config --services | sort
Vérification #
docker compose config doit se résoudre sans avertissement de variable non définie et lister les services attendus — geodb et minio absents, rustfs, qgis3 et qgis4 présents :
app
certbot
createbuckets
db
memcached
mirror_transformation_grids
mkcert
nginx
ofelia
qgis3
qgis4
rustfs
smtp4dev
webdav
worker_wrapper
cp *.bak-2026-09-01 restaure l’état initial en une commande.
Phase B — les migrations de données #
Services toujours arrêtés, chaque étape précédée d’un snapshot Proxmox de l’hôte. Un jeu de scripts dans ./migrate/ pilote la séquence.
graph TB
P0["Étendre le disque de l'hôte\n+ snapshot Proxmox"] --> P1
P1["1 · Base : pg_dump PG13 →\nrestore PG17 + ALTER EXTENSION postgis UPDATE"] --> P2
P2["2 · Stockage : mc mirror\nminio → rustfs, bucket par bucket"] --> P3
P3["3 · Build qgis3, puis qgis4\n(un à la fois, prune du cache entre les deux)"] --> P4
P4["4 · docker compose up -d --build"] --> P5
P5["5 · manage.py migrate / collectstatic / compilemessages"] --> P6
P6["6 · Vérif fonctionnelle : login,\nsync d'un projet QField, job traité par qgis3"]
style P0 fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000
style P1 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style P2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style P3 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style P4 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style P5 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style P6 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
1. Base de données : PostgreSQL 13 → 17 #
Le principe : dumper la base depuis un conteneur PG13 jetable monté sur les fichiers actuels, mettre l’ancien répertoire de données de côté, laisser l’image PG17 initialiser un cluster neuf, puis restaurer.
# conteneur PG13 temporaire sur le répertoire de données ACTUEL
docker run -d --name qfc-pg13-dump \
-v "$PWD/volumes/db/postgresql:/var/lib/postgresql/data" postgis/postgis:13-3.1-alpine
docker exec qfc-pg13-dump pg_dump -U qfieldcloud_db_admin -Fc \
--exclude-table-data=public.spatial_ref_sys -d qfieldcloud_db > migrate/dump/qfieldcloud_db.dump
docker stop -t 30 qfc-pg13-dump && docker rm qfc-pg13-dump
--exclude-table-data=public.spatial_ref_sys : la table des systèmes de coordonnées est repeuplée par CREATE EXTENSION postgis sur la nouvelle image. La ré-importer depuis le dump provoquerait des collisions de clé primaire.
# basculer l'ancien répertoire, en créer un vide
mv volumes/db/postgresql volumes/db/postgresql.pg13.bak
mkdir volumes/db/postgresql
# PG17 initialise un cluster neuf
docker compose up -d db # postgis/postgis:17-3.5-alpine
# restauration
docker compose exec -T db psql -U qfieldcloud_db_admin -d postgres \
-c "DROP DATABASE IF EXISTS qfieldcloud_db;" \
-c "CREATE DATABASE qfieldcloud_db OWNER qfieldcloud_db_admin;"
docker compose exec -T db pg_restore -U qfieldcloud_db_admin -d qfieldcloud_db \
--no-owner --no-privileges < migrate/dump/qfieldcloud_db.dump
docker compose exec -T db psql -U qfieldcloud_db_admin -d qfieldcloud_db \
-c "ALTER EXTENSION postgis UPDATE;"
Le saut PostGIS 3.1 → 3.5 s’est fait sans accroc : pg_restore a rendu la main proprement, et ALTER EXTENSION postgis UPDATE n’avait rien à faire — le dump ne fige pas la version de l’extension, CREATE EXTENSION a directement installé la 3.5.7.
pg_restore: clean. SELECT postgis_full_version() → POSTGIS="3.5.7" PGSQL="170". Comptages identiques à la source : 4 comptes, 1 projet, 87 jobs, 188 migrations Django, 53 tables. L’ancien cluster PG13 reste dans volumes/db/postgresql.pg13.bak.
core_user) — il n’y a pas de table auth_user. Ne vous appuyez pas dessus pour contrôler une restauration.
2. Stockage objet : MinIO → rustfs #
MinIO et rustfs tournent côte à côte sur un réseau isolé, le temps de recopier les buckets avec mc mirror. Deux pièges non anticipés.
rustfs refuse d’écrire.
[FATAL] Server runtime failed: Io error: Permission denied (os error 13)
L’image rustfs tourne sous un utilisateur non-root (rustfs) et n’ajuste pas les droits du volume monté. Le dossier ./volumes/rustfs créé en Phase A appartenait à l’UID de l’hôte. Un chmod sur ce dossier vide suffit :
chmod 777 volumes/rustfs
L’image mc est trop minimale.
Le premier script de mirror bouclait sur la liste des buckets avec awk et sed — tous deux absents de minio/mc:latest. La parade : nommer les buckets explicitement.
NET=qfieldcloud_default
CRED='<access_key> <secret_key>' # identiques à l'ancien MinIO
docker run --rm --network "$NET" --entrypoint /bin/sh minio/mc:latest -c "
mc alias set src http://minio:9000 $CRED
mc alias set dst http://rustfs:9000 $CRED
for b in qfieldcloud-local qfieldcloud-attachments; do
mc mb --ignore-existing dst/\$b
mc mirror --overwrite --preserve src/\$b dst/\$b
done
mc diff src/qfieldcloud-local dst/qfieldcloud-local
mc diff src/qfieldcloud-attachments dst/qfieldcloud-attachments
"
mc diff muet sur les deux buckets — contenu strictement identique. Sur disque, rustfs occupe 480 Mo là où MinIO en prenait 3,4 Go (erasure coding sur 4 « disques »). volumes/minio est conservé tel quel comme secours.
STORAGES du .env — donc la configuration vue par Django — n’a pas bougé d’un octet.
3. Images QGIS #
Deux images à construire, sur un hôte à l’espace disque compté et qui avait déjà bronché : on les enchaîne une par une, avec un docker builder prune et un df -h / entre les deux.
docker compose build qgis3
docker builder prune -f && df -h /
docker compose build qgis4
docker builder prune -f && df -h /
Chaque build prend une dizaine de minutes (installation de QGIS et de la pile GDAL/Qt dans l’image Ubuntu).
qfieldcloud-qgis3 (noble, QGIS 3.44) → 3,24 Go ; qfieldcloud-qgis4 (resolute, QGIS 4.2) → 2,41 Go. Pic à ~17 Go occupés sur les 64, jamais de tension I/O.
4. Démarrer la stack #
docker compose up -d --build
Cette commande reconstruit app (base Python 3.14), nginx, worker_wrapper et createbuckets, puis démarre les 15 services.
Piège : le réseau Docker partagé, supprimé. Le down du compose de migration stockage (section 2) portait — via --env-file .env — le même COMPOSE_PROJECT_NAME que la stack principale. Son down a donc supprimé le réseau qfieldcloud_default. Le conteneur db, créé plus tôt lors de la migration PostgreSQL, y était encore rattaché.
failed to set up container networking: network 59ead981… not found — db en Exited (128).
La correction : recréer le conteneur db. Les données vivent dans le bind mount, la recréation est sans risque.
docker compose up -d --force-recreate db
docker compose up -d # réconcilie le reste sur le nouveau réseau
docker-compose annexe qui doit cohabiter avec une stack en place doit avoir un name: et un projet distincts. Ne lui passez pas le --env-file .env de la stack : il y récupère COMPOSE_PROJECT_NAME et son down emporte le réseau partagé.
5. Migrations et fichiers statiques #
docker compose exec app python manage.py migrate
55 migrations en attente. Deux temps forts : le retrait de geodb (core.0091_remove_geodb) et surtout la scission de l’app core — le modèle Project déménage dans une nouvelle app project (core.0103_detach_project_fks_from_state, project.0001_initial, …, core.0105_detach_project_from_db_state). Tant que ces migrations ne sont pas appliquées, le worker_wrapper boucle sur relation "project_project" does not exist. Prévoyez la fenêtre d’indisponibilité en conséquence.
Puis les fichiers statiques. Le conteneur app tourne en UID 10001, les fichiers de ./volumes/app/staticfiles appartiennent à l’UID 1000 : collectstatic ne peut pas les écraser. Le README du dépôt règle ça avec un sudo chmod 777 / 755 autour de la commande. Sans sudo interactif sous la main, un conteneur jetable root fait le même travail sur le bind mount :
docker run --rm -v "$PWD/volumes/app:/d" alpine chmod -Rf 777 /d
docker compose exec app python manage.py collectstatic --noinput # 505 fichiers
docker run --rm -v "$PWD/volumes/app:/d" alpine chmod -Rf 755 /d
docker compose run --rm --user root app python manage.py compilemessages
6. Vérification fonctionnelle #
curl -sk https://qfieldcloud.webgis.allfabox.fr:8443/api/v1/status/
{"database":"ok","storage":"ok", …} en HTTP 200. docker compose ps : 10 services Up, rustfs healthy, app et worker_wrapper à restarts=0. Côté ORM, Project.objects.first() renvoie le projet migré ; default_storage.bucket.name vaut qfieldcloud-local et liste bien les objets copiés sur rustfs. createbuckets a reconfiguré la politique publique du bucket et activé son versioning.
Reste un test que seul un client peut faire : un pull puis un push QField réel, qui doit déclencher un job traité dans un conteneur qfieldcloud-qgis3 éphémère lancé par le worker_wrapper.
Conclusion #
Ce que vous retenez :
- Sur une instance personnalisée,
docker-compose.ymlet l’override sont des copies maintenues à la main : la mise à jour consiste à rejouer un petit jeu de transformations connues sur le fichier upstream. - Plusieurs nouvelles variables du
.envsont obligatoires (os.environ[...]) : les oublier, c’est un conteneurappqui ne démarre pas. - Le saut PostgreSQL 13 → 17 + PostGIS 3.1 → 3.5 par dump/restore est trivial sur une base de 82 Mo :
pg_restore: cleandu premier coup. - La scission d’une app Django (
core→project) rend le worker inopérant jusqu’aumigrate— c’est le vrai facteur de durée d’indisponibilité, pas les builds. - La bascule MinIO → rustfs (beta) a demandé deux ajustements d’exploitation et zéro correction sur les objets copiés.
- Un
docker-composeannexe mal isolé peut supprimer le réseau de la stack :name:et projet distincts, pas de--env-filepartagé.
volumes/db/postgresql.pg13.bak, volumes/minio, le snapshot Proxmox — jusqu’à ce qu’un vrai usage terrain ait tourné plusieurs jours. Le rollback reste une seule commande (bash migrate/postgres-13-to-17.sh rollback, ou la restauration du snapshot).