Aller au contenu
Auto-héberger QFieldCloud : anatomie d'une stack Docker Compose standalone
  1. Posts/

Auto-héberger QFieldCloud : anatomie d'une stack Docker Compose standalone

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

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.

Vous allez voir comment une instance QFieldCloud standalone s’assemble en Docker Compose : ce que fait chaque service, comment les données sont rangées, et comment déployer le tout sur un LXC.
Info : Il s’agit d’un déploiement standalone — base de données, stockage objet et serveur mail sont embarqués dans la stack, rien n’est délégué à des services managés. L’accès est interne au LAN, avec un certificat TLS auto-signé. C’est un point de départ, documenté ici avant sa mise à jour : la suite est racontée dans la migration vers l’upstream.

Table des matières
#

Vue d’ensemble de l’architecture
#

Un frontal HTTP, un cœur applicatif Django, un pool de workers QGIS, et une couche de données entièrement locale.
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
#

Info : Docker 24+, Docker Compose v2, Git. La stack se cible sur un LXC Proxmox dédié (Debian), mais n’importe quel hôte Docker convient.
Docker Compose
PostGIS
S3 / MinIO
nginx
QGIS
LXC Proxmox
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
Attention : Le 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
Astuce : Tout est en bind mounts sous ./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.
Info : ./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/
Succès : tous les services 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
Alternative : Tant que le retard sur l’upstream reste faible, ce 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
#

Symptôme : les jobs restent en file, 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.

Symptôme : 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.

Symptôme : 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.

Symptôme : QField refuse le certificat.

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=0 sur une instance réellement exposée. Ici, DEBUG=1 est assumé sur un accès LAN fermé.
  • Journaux Docker : max-size / max-file sont déjà posés dans le docker-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
#

Une instance QFieldCloud standalone, c’est quinze services Compose : un frontal nginx, un cœur Django, des workers QGIS lancés à la demande, et une couche de données 100 % locale — PostGIS, MinIO, mail de test.

Ce que vous retenez :

  • Le code upstream vit dans src/ ; docker-compose.yml, l’override et le .env sont des copies maintenues à la main à la racine.
  • Les bind mounts sous ./volumes/ rendent sauvegarde et inspection triviales, au prix de la portabilité.
  • Le worker_wrapper a besoin du Docker de l’hôte (nesting LXC) pour lancer ses conteneurs QGIS.
  • L’endpoint_url S3 doit être une IP joignable de partout, conteneurs et hôte compris.
Astuce : Cette stack « maison » fonctionne, mais elle diverge de l’upstream à chaque release. La suite logique est double : migrer l’existant vers l’upstream courant (article 2), et — à terme — documenter un déploiement propre depuis l’upstream, sans historique à reprendre (article 3).
opengisch/QFieldCloud

Articles connexes