Introduction #
QFieldCloud est le pont entre QGIS sur le bureau et QField sur le terrain : il synchronise projets, couches et photos, gère les collaborateurs et rejoue les modifications faites hors ligne. OPENGIS.ch en propose une version hébergée ; le dépôt permet aussi de l’auto-héberger.
J’ai fait ce second choix, sur un conteneur LXC Proxmox dédié — la LXC « webgis » — aux côtés d’autres briques SIG comme Lizmap. Cet article décrit la stack telle que je l’avais montée : ses partis pris, ses quinze services, son déploiement et sa maintenance.
Table des matières #
- Vue d’ensemble de l’architecture
- Prérequis techniques
- Structure du projet
- Configuration des services
- Déploiement étape par étape
- Vérification et tests
- Maintenance et mise à jour
- Troubleshooting
- Bonnes pratiques
- Conclusion
Vue d’ensemble de l’architecture #
graph TB
subgraph "Frontend"
NG["nginx\nreverse-proxy + TLS"]
MK["mkcert / certbot\ncertificats"]
end
subgraph "Application"
APP["app\nDjango / gunicorn"]
WW["worker_wrapper\ndépile les jobs"]
QG["qgis\nimage worker headless"]
OF["ofelia\ncron"]
MC["memcached"]
end
subgraph "Données"
DB[("db\nPostGIS 13")]
GEO[("geodb\nPostGIS 13")]
MO[("minio\nstockage objet S3")]
WD["webdav"]
SM["smtp4dev\nfaux SMTP"]
end
NG -->|"proxy"| APP
APP --> DB
APP --> GEO
APP --> MO
APP --> MC
APP --> SM
WW --> APP
WW -->|"docker.sock"| QG
OF -->|"runcrons"| APP
style NG fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
style APP fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style WW fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style DB fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
style GEO fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
style MO fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
| Service | Rôle | Port hôte | Image |
|---|---|---|---|
nginx |
Reverse-proxy, terminaison TLS, sert les fichiers statiques | 8480 (HTTP), 8443 (HTTPS) | build src/docker-nginx |
mkcert |
Génère un certificat auto-signé pour le LAN | — | vishnunair/docker-mkcert |
certbot |
Renouvellement Let’s Encrypt (prêt si exposition publique) | — | certbot/certbot |
app |
API et back-office Django (gunicorn) | — (8000 interne) | build src/docker-app |
worker_wrapper |
Dépile les jobs, lance un conteneur QGIS par job | — | build src/docker-app |
qgis |
Image QGIS headless, modèle des conteneurs éphémères | — | build src/docker-qgis |
ofelia |
Planificateur : runcrons chaque minute |
— | mcuadros/ofelia:0.3.18 |
memcached |
Cache applicatif | — | memcached:1 |
db |
Base applicative qfieldcloud_db |
5433 | postgis/postgis:13-3.1-alpine |
geodb |
Base PostGIS pour les données métier des utilisateurs | 5434 | postgis/postgis:13-3.1-alpine |
minio |
Stockage objet S3 (projets, paquets, photos) | 8009 (API), 8010 (console) | minio/minio:RELEASE.2025-02-18… |
createbuckets |
One-shot : crée et configure les buckets au démarrage | — | build src/docker-createbuckets |
smtp4dev |
Faux serveur SMTP, capture les mails dans une UI web | 2525 (SMTP), 8012 (web) | rnwood/smtp4dev:v3 |
webdav |
Backend de stockage alternatif (optionnel) | 8020 | bytemark/webdav:2.4 |
mirror_transformation_grids |
Miroir des grilles de transformation PROJ | — | k3rnelpan1c/alpine-wget |
Le flux d’une synchronisation :
sequenceDiagram
participant QF as QField / QGIS
participant NG as nginx
participant APP as app
participant DB as db
participant MO as minio
participant WW as worker_wrapper
participant QG as conteneur qgis
QF->>NG: HTTPS /api/v1/…
NG->>APP: proxy
APP->>DB: métadonnées projet
APP->>MO: fichiers (get / put)
APP-->>QF: réponse JSON
Note over WW: un job « package » est en file
WW->>QG: docker run (image qgis)
QG->>MO: lit le projet, écrit le paquet
QG-->>WW: terminé
Prérequis techniques #
| Ressource | Minimum confortable |
|---|---|
| vCPU | 4 |
| RAM | 6 Go |
| Disque | 64 Go (les images QGIS pèsent 2–3 Go chacune, plus le cache de build) |
| OS | Debian 12/13, Docker CE |
worker_wrapper lance un conteneur QGIS par job, via /var/run/docker.sock monté dans le conteneur. Dans un LXC Proxmox, cela impose un conteneur avec features nesting=1 (Docker imbriqué). Sans ça, les jobs restent en file indéfiniment.
Structure du projet #
Le dépôt QFieldCloud fournit son propre docker-compose.yml et des overrides. Dès qu’on personnalise le déploiement, on en garde une copie locale à la racine, et le code upstream vit dans un sous-dossier src/.
/opt/docker/qfieldcloud-stack/
├── src/ # clone de opengisch/QFieldCloud (code upstream)
│ ├── docker-app/ docker-nginx/ docker-qgis/ docker-createbuckets/
│ └── …
├── docker-compose.yml # copie maintenue à la main (contextes → ./src/…)
├── docker-compose.override.standalone.yml
├── .env
├── README.md
└── volumes/ # TOUTES les données, en bind mounts
├── app/{staticfiles,mediafiles}
├── db/postgresql # cluster de la base applicative
├── db/postgis # cluster de la geodb
├── minio/data{1,2,3,4} # 4 « disques » MinIO
├── nginx/{certs,certbot,config.d,dhparams}
├── smtp4dev/ webdav/
└── worker_wrapper/transformation_grids
| Fichier | Rôle |
|---|---|
docker-compose.yml |
Services app, nginx, worker_wrapper, qgis, ofelia, memcached — contextes de build pointés sur ./src/… |
docker-compose.override.standalone.yml |
Ajoute les services embarqués : db, geodb, minio + createbuckets, smtp4dev, webdav |
.env |
Hôte, ports, secrets, configuration STORAGES |
src/ |
Code cloné — jamais modifié, remplacé à chaque mise à jour |
./volumes/, pas en volumes Docker nommés. La sauvegarde devient un tar du dossier (ou un snapshot Proxmox), l’inspection se fait à la main, et la restauration est triviale. On perd la portabilité des volumes nommés, mais pour une instance unique sur un hôte c’est un bon compromis.
./volumes/ et .env sont exclus du versionnement. Seuls les trois fichiers de configuration (docker-compose.yml, l’override, le README) et le pointeur de src/ sont suivis.
Configuration des services #
Frontend — nginx, mkcert, certbot #
nginx termine le TLS et proxifie tout vers app. Il sert aussi directement les fichiers statiques et médias collectés, montés depuis ./volumes/app/.
nginx:
build:
context: ./src/docker-nginx
volumes:
- ./volumes/nginx/certs/:/etc/nginx/certs/:ro # certificat mkcert
- ./volumes/nginx/config.d:/etc/nginx/config.d/:ro # surcharges d'instance
- ./volumes/app/staticfiles:/var/www/html/staticfiles/ # servis directement
- ./volumes/app/mediafiles:/var/www/html/mediafiles/
ports:
- ${WEB_HTTP_PORT}:80 # 8480
- ${WEB_HTTPS_PORT}:443 # 8443
environment:
QFIELDCLOUD_HOST: ${QFIELDCLOUD_HOST}
QFIELDCLOUD_TLS_CERT: ${QFIELDCLOUD_TLS_CERT}
QFIELDCLOUD_TLS_KEY: ${QFIELDCLOUD_TLS_KEY}
mkcert génère un certificat auto-signé pour qfieldcloud.webgis.allfabox.fr (accès LAN uniquement, LETSENCRYPT_STAGING=1). certbot est présent mais inactif — prêt à prendre le relais le jour d’une exposition publique.
Application — app, worker_wrapper, qgis #
app est le conteneur Django (gunicorn), construit depuis src/docker-app avec la cible webserver_runtime.
app: &default-django
build:
context: ./src/docker-app
target: webserver_runtime
command: >
gunicorn qfieldcloud.wsgi:application --bind 0.0.0.0:8000
--timeout ${GUNICORN_TIMEOUT_S} --workers ${GUNICORN_WORKERS} --threads ${GUNICORN_THREADS}
volumes:
- ./volumes/app/staticfiles:/usr/src/app/staticfiles
- ./volumes/app/mediafiles:/usr/src/app/mediafiles
worker_wrapper réutilise la même image (<<: *default-django, cible worker_wrapper_runtime), tourne en root et se voit monter /var/run/docker.sock : c’est lui qui lance un conteneur qgis éphémère pour chaque job de rendu ou de packaging.
worker_wrapper:
<<: *default-django
build: { context: ./src/docker-app, target: worker_wrapper_runtime }
command: python manage.py dequeue
user: root
volumes:
- ./volumes/worker_wrapper/transformation_grids:/transformation_grids
- /var/run/docker.sock:/var/run/docker.sock
- ${TMP_DIRECTORY}:/tmp
qgis n’est pas un service qui « tourne » : son unique rôle est de construire l’image QGIS headless (command: bash -c "echo QGIS built") que le worker_wrapper réutilise.
ofelia déclenche python manage.py runcrons chaque minute (via des labels sur app), memcached sert de cache.
Données — db, geodb, minio, smtp4dev, webdav #
db:
image: postgis/postgis:${POSTGIS_IMAGE_VERSION} # 13-3.1-alpine
volumes:
- ./volumes/db/postgresql:/var/lib/postgresql/data/
ports:
- ${HOST_POSTGRES_PORT}:5432 # 5433
geodb:
image: postgis/postgis:${POSTGIS_IMAGE_VERSION}
volumes:
- ./volumes/db/postgis:/var/lib/postgresql
ports:
- ${HOST_GEODB_PORT}:5432 # 5434
db porte la base applicative. geodb est une seconde instance PostGIS, séparée, destinée aux couches PostGIS que les utilisateurs veulent héberger côté serveur — une fonctionnalité que QFieldCloud proposait alors nativement.
minio fournit le stockage objet S3, réparti sur quatre « disques » (data1…4) pour l’erasure coding, avec les buckets qfieldcloud-local et qfieldcloud-attachments. createbuckets les crée et pose la politique publique au premier démarrage.
minio:
image: minio/minio:RELEASE.2025-02-18T16-25-55Z
volumes:
- ./volumes/minio/data1:/data1
- ./volumes/minio/data2:/data2
- ./volumes/minio/data3:/data3
- ./volumes/minio/data4:/data4
command: server --console-address ":9001" --address ":9000" /data{1...4}
ports:
- ${MINIO_API_PORT}:9000 # 8009 — doit correspondre à STORAGES.endpoint_url
- ${MINIO_BROWSER_PORT}:9001 # 8010
smtp4dev capture tous les mails sortants (vérification de compte, réinitialisation de mot de passe) dans une interface web — indispensable sur un déploiement sans vrai relais SMTP. webdav est un backend de stockage alternatif, laissé en place mais non utilisé.
Le .env, en résumé
#
| Variable | Valeur | Note |
|---|---|---|
QFIELDCLOUD_HOST |
qfieldcloud.webgis.allfabox.fr |
nom résolu sur le LAN |
DEBUG / ENVIRONMENT |
1 / production |
debug assumé sur un accès interne |
POSTGIS_IMAGE_VERSION |
13-3.1-alpine |
même image pour db et geodb |
STORAGES |
JSON pointant http://10.10.20.70:8009 |
l’endpoint_url doit être joignable des conteneurs et de l’hôte |
WEB_HTTP_PORT / WEB_HTTPS_PORT |
8480 / 8443 |
plage 84xx pour cohabiter avec les autres apps du LXC |
SECRET_KEY / SALT_KEY / mots de passe |
générés une fois | openssl rand -base64 64 |
graph LR
CB["createbuckets"] -->|"healthy"| MO[("minio")]
APP["app"] --> DB[("db")]
APP --> GEO[("geodb")]
APP --> MO
APP --> MC["memcached"]
WW["worker_wrapper"] --> APP
WW -->|"docker.sock"| DKR["Docker hôte"]
NG["nginx"] --> APP
style MO fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
style APP fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
Déploiement étape par étape #
1. Préparer l’hôte #
Un LXC Proxmox privilégié avec nesting=1, Docker CE installé, un utilisateur dédié et l’arborescence /opt/docker/qfieldcloud-stack. (Le détail LXC + Docker est couvert dans la série Perfect Homelab.)
2. Récupérer le code #
cd /opt/docker/qfieldcloud-stack
git clone --recurse-submodules https://github.com/opengisch/QFieldCloud.git src
3. Poser la configuration #
Copier docker-compose.yml, docker-compose.override.standalone.yml et .env à la racine (à partir des exemples du dépôt), puis ajuster le .env : QFIELDCLOUD_HOST, la plage de ports, les secrets (openssl rand -base64 64), et le bloc STORAGES avec l’IP LAN de l’hôte.
4. Construire et démarrer #
docker compose up -d --build --remove-orphans
graph TD
A["docker compose up -d --build"] --> B["build app / nginx / qgis / createbuckets"]
B --> C["db, geodb, minio, memcached démarrent"]
C --> D["createbuckets crée les buckets"]
D --> E["app + worker_wrapper démarrent"]
E --> F["nginx expose 8443"]
style F fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
5. Initialiser l’application #
docker compose exec app python manage.py migrate
sudo chmod -Rf 777 ./volumes/app/*
docker compose run app python manage.py collectstatic --noinput
sudo chmod -Rf 755 ./volumes/app/*
docker compose run app python manage.py createsuperuser --username allfab --email allfab@gmail.com
docker compose run --user root app python manage.py compilemessages
Vérification et tests #
docker compose ps
curl -k https://qfieldcloud.webgis.allfabox.fr:8443/api/v1/status/
Up (les conteneurs qgis, createbuckets et mirror_transformation_grids sont des one-shot, Exited (0) est normal). /api/v1/status/ renvoie {"database":"ok","storage":"ok"}. Le back-office répond sur https://…:8443/admin/.
Test de bout en bout : dans QGIS, plugin QFieldSync → se connecter à l’instance → pousser un projet ; sur mobile, l’ouvrir dans QField, modifier hors ligne, resynchroniser. Le worker_wrapper doit journaliser un job package traité dans un conteneur qgis éphémère.
Maintenance et mise à jour #
Sauvegarde. Stack arrêtée, une archive des données suffit :
docker compose down
tar czf qfc-volumes-$(date +%F).tar.gz volumes/
# ou : snapshot Proxmox du LXC
Pour la base seule : docker compose exec db pg_dump -U qfieldcloud_db_admin qfieldcloud_db > dump.sql.
Logs et ressources.
docker compose logs -f app worker_wrapper
docker stats
docker system df
Mise à jour. La procédure du dépôt : on met l’ancien code de côté, on reclone, on compare les .env, on reconstruit.
docker compose down --remove-orphans
mv src _src
git clone --recurse-submodules https://github.com/opengisch/QFieldCloud.git src
wget https://raw.githubusercontent.com/opengisch/QFieldCloud/refs/heads/master/.env.example
diff .env .env.example
docker compose up -d --build
diff se règle en quelques minutes. Accumulé sur plusieurs versions majeures, il devient un vrai chantier — c’est l’objet de l’article suivant, où cette même procédure débouche sur quatre migrations imbriquées (images QGIS scindées, MinIO → rustfs, PostGIS 13 → 17, retrait de geodb).
Troubleshooting #
worker_wrapper ne lance aucun conteneur QGIS.
Cause : pas d’accès à /var/run/docker.sock (LXC sans nesting=1, ou socket non monté). Solution : activer features nesting=1 sur le LXC, vérifier le montage du socket dans le service worker_wrapper, redémarrer.
collectstatic échoue en PermissionError.
Cause : le conteneur app n’a pas le même UID que le propriétaire de ./volumes/app/staticfiles. Solution : l’encadrement chmod 777 / 755 du README ; ou faire tourner collectstatic via un conteneur root.
minio reste unhealthy, les uploads échouent.
Cause : l’endpoint_url du bloc STORAGES n’est pas joignable depuis tous les acteurs (conteneurs et hôte, car les workers QGIS y accèdent depuis le Docker de l’hôte). Solution : y mettre l’IP LAN de l’hôte, jamais localhost ni 127.0.0.1.
Cause : certificat auto-signé mkcert. Solution : importer volumes/nginx/certs/rootCA.pem sur l’appareil, ou basculer sur un vrai certificat Let’s Encrypt via le service certbot déjà présent.
flowchart TD
A["Job bloqué en file"] --> B{"worker_wrapper\nlance un conteneur ?"}
B -->|"non"| C["Vérifier nesting=1\n+ montage docker.sock"]
B -->|"oui, mais échoue"| D{"le conteneur qgis\naccède à minio ?"}
D -->|"non"| E["endpoint_url = IP LAN\n(pas localhost)"]
D -->|"oui"| F["Lire les logs du conteneur qgis"]
style C fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000
style E fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000
Bonnes pratiques #
- Secrets : les générer une fois (
openssl rand -base64 64), ne jamais committer le.env. - DEBUG :
DEBUG=0sur une instance réellement exposée. Ici,DEBUG=1est assumé sur un accès LAN fermé. - Journaux Docker :
max-size/max-filesont déjà posés dans ledocker-compose.yml. - geodb : ne l’activer que si des utilisateurs poussent vraiment des couches PostGIS — sinon c’est un cluster de plus à sauvegarder pour rien.
- Disque : dimensionner celui du LXC large dès le départ : les deux images QGIS et le cache de build saturent vite un disque de 32 Go.
Conclusion #
Ce que vous retenez :
- Le code upstream vit dans
src/;docker-compose.yml, l’override et le.envsont des copies maintenues à la main à la racine. - Les bind mounts sous
./volumes/rendent sauvegarde et inspection triviales, au prix de la portabilité. - Le
worker_wrappera besoin du Docker de l’hôte (nesting LXC) pour lancer ses conteneurs QGIS. - L’
endpoint_urlS3 doit être une IP joignable de partout, conteneurs et hôte compris.