Aller au contenu
Migrer QFieldCloud auto-hébergé vers l'upstream : une mise à jour, quatre migrations
  1. Posts/

Migrer QFieldCloud auto-hébergé vers l'upstream : une mise à jour, quatre migrations

Fabien ALLAMANCHE
Auteur
Fabien ALLAMANCHE
Géomaticien @ Vienne Condrieu Agglomération
Sommaire

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.

Info — « l’upstream » : dans tout l’article, le terme désigne le dépôt public 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.
Vous allez suivre la migration complète d’une instance QFieldCloud auto-hébergée vers l’upstream : la cartographie des changements, les arbitrages, puis la mise en œuvre de bout en bout — préparation des fichiers, migrations de la base et du stockage objet, builds, démarrage et vérification, avec les frictions rencontrées en chemin.

Table des matières
#

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.

Info : Cet article suppose une instance standalone (base PostGIS, stockage objet et serveur mail embarqués dans la stack), déployée via 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.
Attention : Le même hôte avait connu un blocage 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 :

  1. Code applicatifapp, 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.
  2. Images QGIS — le service qgis unique est scindé en qgis3 (Ubuntu noble, QGIS 3.44) et qgis4 (Ubuntu resolute, QGIS 4.2), même contexte de build, arguments différents. Deux images lourdes à construire au lieu d’une.
  3. Stockage objet — l’upstream remplace minio par rustfs dans 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.
  4. Base de données — le défaut de POSTGIS_IMAGE_VERSION passe de 13-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
Attention : 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 qgisqgis3 + 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.

Info : L’upstream monte un volume 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 minio est remplacé par rustfs (image rustfs/rustfs:1.0.0-beta.11, healthcheck, ports S3 et console) ;
  • le service geodb et le bloc volumes: 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_VERSION17-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_NAMEQFIELDCLOUD_QGIS3_IMAGE_NAME + nouvelle QFIELDCLOUD_QGIS4_IMAGE_NAME
Fusionner DEBUG_QGIS_LIBQFIELDSYNC_HOST_PATH + DEBUG_QGIS_QFIELDCLOUD_SDK_HOST_PATHDEBUG_QGIS_WORKER_HOST_PATH
Supprimer tout le bloc GEODB_* et HOST_GEODB_PORT
Astuce : Pour éviter de toucher au JSON 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
Succès : La configuration fusionnée est valide, aucune variable manquante. À ce stade, aucun conteneur n’a été touché : 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.

Succès : 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.
Info : QFieldCloud utilise un modèle utilisateur maison (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.

Erreur : [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
"
Succès : 91 objets / 781 Mio copiés, 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.
Astuce : Les identifiants rustfs sont ceux de l’ancien MinIO (voir Phase A). Le bloc 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).

Succès : 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é.

Erreur : failed to set up container networking: network 59ead981… not founddb 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
Astuce : Un 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/
Succès : {"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
#

La mise à jour « en quelques lignes » était en réalité quatre migrations imbriquées. Aucune n’a abîmé de donnée : toutes les frictions étaient opérationnelles — droits de volume, image outil trop maigre, réseau Docker partagé.

Ce que vous retenez :

  • Sur une instance personnalisée, docker-compose.yml et 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 .env sont obligatoires (os.environ[...]) : les oublier, c’est un conteneur app qui 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: clean du premier coup.
  • La scission d’une app Django (coreproject) rend le worker inopérant jusqu’au migrate — 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-compose annexe mal isolé peut supprimer le réseau de la stack : name: et projet distincts, pas de --env-file partagé.
Astuce : Gardez les filets — 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).
opengisch/QFieldCloud

Articles connexes