[{"data":1,"prerenderedAt":1259},["ShallowReactive",2],{"doc:\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Frun-pyqgis-in-docker-container":3},{"id":4,"title":5,"body":6,"description":1248,"extension":1249,"meta":1250,"navigation":230,"path":1255,"seo":1256,"stem":1257,"__hash__":1258},"docs\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Frun-pyqgis-in-docker-container\u002Findex.md","Run PyQGIS in a Docker Container",{"type":7,"value":8,"toc":1235},"minimark",[9,13,17,26,179,184,204,208,295,334,338,455,476,480,483,508,522,599,603,610,757,778,782,785,866,895,912,990,994,1001,1071,1081,1085,1146,1150,1153,1157,1163,1172,1186,1192,1198,1202,1231],[10,11,5],"h1",{"id":12},"run-pyqgis-in-a-docker-container",[14,15,16],"p",{},"\"It works on my machine\" has a specific meaning in geospatial work: your PROJ has the grid file that the server's PROJ does not, so the same reprojection lands two metres away. QGIS, GDAL and PROJ are a stack, not three independent packages, and the only reliable way to run the same analysis next year is to freeze the whole stack. A container does that in about fifteen lines.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002F","Headless QGIS and Server Automation",". It covers choosing a base image, adding your own dependencies, mounting data and scripts, running with no display, and the two file-permission problems that catch everyone on the first run.",[14,27,28],{},[29,30,35,39,43,50,67,76,86,92,100,105,108,112,115,119,125,129,136,141,145,149,154,158,161,165,172,175],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 288","img","A container image layering the operating system, PROJ and GDAL, QGIS, Python dependencies and the script, with data and outputs mounted from the host at run time","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"What is baked into the image and what is mounted at run time",[40,41,42],"desc",{},"The image contains four stacked layers: a base operating system, PROJ and GDAL with their grid files, QGIS and its Python bindings, and the project's own Python dependencies plus the script. Outside the image, two host directories are mounted at run time: input data read-only and an output directory writable. A caption notes that everything inside the image is pinned by version and everything mounted changes between runs.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","288","#f6f3ea",[51,52,53],"defs",{},[54,55,62],"marker",{"id":56,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"dkArrow","0 0 10 10","8","5","7","auto-start-reverse",[63,64],"path",{"d":65,"fill":66},"M0 0 L10 5 L0 10 z","#2f3b35",[68,69,75],"text",{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","Pin the stack, mount the data",[44,77],{"x":78,"y":79,"width":80,"height":81,"rx":82,"fill":83,"stroke":84,"style":85},"24","48","400","212","10","#eef7f4","#0f766e","stroke-width:2.5",[68,87,91],{"x":88,"y":89,"style":90,"fill":84,"textAnchor":74},"224","72","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","the image — identical every run",[44,93],{"x":79,"y":81,"width":94,"height":95,"rx":96,"fill":97,"stroke":98,"style":99},"352","34","6","#fffdf7","#59645f","stroke-width:1.5",[68,101,104],{"x":88,"y":102,"style":103,"fill":66,"textAnchor":74},"234","text-anchor:middle;font-size:11px;font-family:sans-serif","Ubuntu 24.04",[44,106],{"x":79,"y":107,"width":94,"height":95,"rx":96,"fill":97,"stroke":98,"style":99},"170",[68,109,111],{"x":88,"y":110,"style":103,"fill":66,"textAnchor":74},"192","PROJ 9.x + grid files · GDAL 3.8",[44,113],{"x":79,"y":114,"width":94,"height":95,"rx":96,"fill":97,"stroke":98,"style":99},"128",[68,116,118],{"x":88,"y":117,"style":103,"fill":66,"textAnchor":74},"150","QGIS 3.34 LTR + python3-qgis",[44,120],{"x":79,"y":121,"width":94,"height":95,"rx":96,"fill":122,"stroke":123,"style":124},"86","#edf8e9","#15803d","stroke-width:2",[68,126,128],{"x":88,"y":127,"style":103,"fill":66,"textAnchor":74},"108","requirements.txt + your script",[44,130],{"x":131,"y":89,"width":132,"height":133,"rx":82,"fill":134,"stroke":135,"style":124},"464","272","76","#eff3ff","#2563eb",[68,137,140],{"x":138,"y":139,"style":90,"fill":135,"textAnchor":74},"600","98","\u002Fdata — mounted read-only",[68,142,144],{"x":138,"y":143,"style":103,"fill":66,"textAnchor":74},"120","source layers from the host",[68,146,148],{"x":138,"y":147,"style":103,"fill":66,"textAnchor":74},"138","changes between runs",[44,150],{"x":131,"y":151,"width":132,"height":133,"rx":82,"fill":152,"stroke":153,"style":124},"164","#fdf2e2","#b45309",[68,155,157],{"x":138,"y":156,"style":90,"fill":153,"textAnchor":74},"190","\u002Fout — mounted writable",[68,159,160],{"x":138,"y":81,"style":103,"fill":66,"textAnchor":74},"results survive the container",[68,162,164],{"x":138,"y":163,"style":103,"fill":66,"textAnchor":74},"230","watch the file ownership",[166,167],"line",{"x1":168,"y1":169,"x2":170,"y2":169,"stroke":66,"style":171},"424","110","458","stroke-width:2;marker-end:url(#dkArrow)",[166,173],{"x1":168,"y1":174,"x2":170,"y2":174,"stroke":66,"style":171},"200",[68,176,178],{"x":70,"y":177,"style":103,"fill":98,"textAnchor":74},"280","Anything written inside the container and not mounted out is gone when it exits",[180,181,183],"h2",{"id":182},"prerequisites","Prerequisites",[185,186,187,195,201],"ul",{},[188,189,190,194],"li",{},[191,192,193],"strong",{},"Docker"," (or Podman, which accepts the same files) on the machine that will run the job.",[188,196,197,198,200],{},"A PyQGIS script that already runs headlessly — see ",[21,199,24],{"href":23}," for the initialisation pattern.",[188,202,203],{},"Somewhere to put input data and collect output on the host.",[180,205,207],{"id":206},"a-minimal-image","A minimal image",[209,210,215],"pre",{"className":211,"code":212,"language":213,"meta":214,"style":214},"language-dockerfile shiki shiki-themes github-dark","FROM qgis\u002Fqgis:release-3_34\n\nENV QT_QPA_PLATFORM=offscreen \\\n    PYTHONUNBUFFERED=1 \\\n    XDG_RUNTIME_DIR=\u002Ftmp\u002Fruntime\n\nWORKDIR \u002Fapp\nCOPY requirements.txt .\nRUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt\n\nCOPY nightly_export.py .\n\nENTRYPOINT [\"python3\", \"\u002Fapp\u002Fnightly_export.py\"]\n","dockerfile","",[216,217,218,225,232,238,244,250,255,261,267,273,278,284,289],"code",{"__ignoreMap":214},[219,220,222],"span",{"class":166,"line":221},1,[219,223,224],{},"FROM qgis\u002Fqgis:release-3_34\n",[219,226,228],{"class":166,"line":227},2,[219,229,231],{"emptyLinePlaceholder":230},true,"\n",[219,233,235],{"class":166,"line":234},3,[219,236,237],{},"ENV QT_QPA_PLATFORM=offscreen \\\n",[219,239,241],{"class":166,"line":240},4,[219,242,243],{},"    PYTHONUNBUFFERED=1 \\\n",[219,245,247],{"class":166,"line":246},5,[219,248,249],{},"    XDG_RUNTIME_DIR=\u002Ftmp\u002Fruntime\n",[219,251,253],{"class":166,"line":252},6,[219,254,231],{"emptyLinePlaceholder":230},[219,256,258],{"class":166,"line":257},7,[219,259,260],{},"WORKDIR \u002Fapp\n",[219,262,264],{"class":166,"line":263},8,[219,265,266],{},"COPY requirements.txt .\n",[219,268,270],{"class":166,"line":269},9,[219,271,272],{},"RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt\n",[219,274,276],{"class":166,"line":275},10,[219,277,231],{"emptyLinePlaceholder":230},[219,279,281],{"class":166,"line":280},11,[219,282,283],{},"COPY nightly_export.py .\n",[219,285,287],{"class":166,"line":286},12,[219,288,231],{"emptyLinePlaceholder":230},[219,290,292],{"class":166,"line":291},13,[219,293,294],{},"ENTRYPOINT [\"python3\", \"\u002Fapp\u002Fnightly_export.py\"]\n",[14,296,297,300,301,304,305,308,309,312,313,316,317,320,321,324,325,329,330,333],{},[191,298,299],{},"Breakdown:"," The official ",[216,302,303],{},"qgis\u002Fqgis"," images carry QGIS with its Python bindings and a matched GDAL and PROJ; pinning ",[216,306,307],{},"release-3_34"," rather than ",[216,310,311],{},"latest"," is the entire point of the exercise. ",[216,314,315],{},"QT_QPA_PLATFORM=offscreen"," set in the image means no caller has to remember it. ",[216,318,319],{},"PYTHONUNBUFFERED=1"," makes log lines appear as they happen rather than in a burst when the process ends, which matters when you are watching a job that has been running for ten minutes. ",[216,322,323],{},"--break-system-packages"," is needed because the image's Python is externally managed; installing into the system interpreter is correct here precisely because the container ",[326,327,328],"em",{},"is"," the environment. ",[216,331,332],{},"XDG_RUNTIME_DIR"," silences a Qt warning that otherwise decorates every log line.",[180,335,337],{"id":336},"build-and-run-it","Build and run it",[209,339,343],{"className":340,"code":341,"language":342,"meta":214,"style":214},"language-bash shiki shiki-themes github-dark","docker build -t nightly-export:3.34 .\n\ndocker run --rm \\\n  -v \u002Fsrv\u002Fgis\u002Fdata:\u002Fdata:ro \\\n  -v \u002Fsrv\u002Fgis\u002Foutput:\u002Fout \\\n  -e QGIS_AUTH_PASSWORD_FILE=\u002Frun\u002Fsecrets\u002Fqgis_master \\\n  --user \"$(id -u):$(id -g)\" \\\n  nightly-export:3.34 --source \u002Fdata\u002Freadings.gpkg --out \u002Fout\n","bash",[216,344,345,365,369,382,392,401,411,438],{"__ignoreMap":214},[219,346,347,351,355,359,362],{"class":166,"line":221},[219,348,350],{"class":349},"svObZ","docker",[219,352,354],{"class":353},"sU2Wk"," build",[219,356,358],{"class":357},"sDLfK"," -t",[219,360,361],{"class":353}," nightly-export:3.34",[219,363,364],{"class":353}," .\n",[219,366,367],{"class":166,"line":227},[219,368,231],{"emptyLinePlaceholder":230},[219,370,371,373,376,379],{"class":166,"line":234},[219,372,350],{"class":349},[219,374,375],{"class":353}," run",[219,377,378],{"class":357}," --rm",[219,380,381],{"class":357}," \\\n",[219,383,384,387,390],{"class":166,"line":240},[219,385,386],{"class":357},"  -v",[219,388,389],{"class":353}," \u002Fsrv\u002Fgis\u002Fdata:\u002Fdata:ro",[219,391,381],{"class":357},[219,393,394,396,399],{"class":166,"line":246},[219,395,386],{"class":357},[219,397,398],{"class":353}," \u002Fsrv\u002Fgis\u002Foutput:\u002Fout",[219,400,381],{"class":357},[219,402,403,406,409],{"class":166,"line":252},[219,404,405],{"class":357},"  -e",[219,407,408],{"class":353}," QGIS_AUTH_PASSWORD_FILE=\u002Frun\u002Fsecrets\u002Fqgis_master",[219,410,381],{"class":357},[219,412,413,416,419,422,425,428,430,433,436],{"class":166,"line":257},[219,414,415],{"class":357},"  --user",[219,417,418],{"class":353}," \"$(",[219,420,421],{"class":349},"id",[219,423,424],{"class":357}," -u",[219,426,427],{"class":353},"):$(",[219,429,421],{"class":349},[219,431,432],{"class":357}," -g",[219,434,435],{"class":353},")\"",[219,437,381],{"class":357},[219,439,440,443,446,449,452],{"class":166,"line":263},[219,441,442],{"class":353},"  nightly-export:3.34",[219,444,445],{"class":357}," --source",[219,447,448],{"class":353}," \u002Fdata\u002Freadings.gpkg",[219,450,451],{"class":357}," --out",[219,453,454],{"class":353}," \u002Fout\n",[14,456,457,459,460,463,464,467,468,471,472,475],{},[191,458,299],{}," ",[216,461,462],{},"--rm"," deletes the container when it exits, so a nightly job does not accumulate hundreds of dead containers. Mounting the input ",[216,465,466],{},":ro"," makes an accidental write impossible, which is worth the six characters. ",[216,469,470],{},"--user \"$(id -u):$(id -g)\""," is the fix for the most common first-run complaint: without it the container runs as root and every output file lands on the host owned by root. Anything the script needs to know arrives as arguments after the image name, because the ",[216,473,474],{},"ENTRYPOINT"," is the interpreter and script.",[180,477,479],{"id":478},"keep-proj-honest","Keep PROJ honest",[14,481,482],{},"The reason two machines disagree about a coordinate is almost always PROJ's transformation grids. The base image ships the standard set; anything national — the Ordnance Survey's OSTN15, Germany's BeTA2007, a state-specific NADCON grid — has to be added deliberately.",[209,484,486],{"className":211,"code":485,"language":213,"meta":214,"style":214},"RUN mkdir -p \u002Fusr\u002Fshare\u002Fproj && \\\n    curl -fsSL -o \u002Fusr\u002Fshare\u002Fproj\u002Fuk_os_OSTN15_NTv2_OSGBtoETRS.tif \\\n    https:\u002F\u002Fcdn.proj.org\u002Fuk_os_OSTN15_NTv2_OSGBtoETRS.tif\nENV PROJ_NETWORK=OFF\n",[216,487,488,493,498,503],{"__ignoreMap":214},[219,489,490],{"class":166,"line":221},[219,491,492],{},"RUN mkdir -p \u002Fusr\u002Fshare\u002Fproj && \\\n",[219,494,495],{"class":166,"line":227},[219,496,497],{},"    curl -fsSL -o \u002Fusr\u002Fshare\u002Fproj\u002Fuk_os_OSTN15_NTv2_OSGBtoETRS.tif \\\n",[219,499,500],{"class":166,"line":234},[219,501,502],{},"    https:\u002F\u002Fcdn.proj.org\u002Fuk_os_OSTN15_NTv2_OSGBtoETRS.tif\n",[219,504,505],{"class":166,"line":240},[219,506,507],{},"ENV PROJ_NETWORK=OFF\n",[14,509,510,512,513,516,517,521],{},[191,511,299],{}," Downloading the grid at build time bakes it into the image, so the container is self-contained and reproducible. ",[216,514,515],{},"PROJ_NETWORK=OFF"," then forbids PROJ from silently fetching grids over the network at run time — which sounds convenient until a firewall blocks it and the transformation quietly falls back to a less accurate path. Fail loudly on a missing grid instead; the accuracy difference is metres, and it is invisible in the output. The consequences of getting this wrong are laid out in ",[21,518,520],{"href":519},"\u002Fspatial-data-processing-automation\u002Fcoordinate-reference-systems\u002Fhandling-missing-crs-in-pyqgis\u002F","Handling Missing CRS in PyQGIS",".",[14,523,524],{},[29,525,528,531,534,537,540,545,549,554,556,560,564,568,572,574,576,580,585,588,592,595],{"viewBox":526,"role":32,"ariaLabel":527,"xmlns":34},"0 0 760 258","Two containers running the same reprojection: one with the national grid file baked in producing a centimetre-accurate result, one without producing a result shifted by metres with no error",[36,529,530],{},"The same reprojection, with and without the grid file",[40,532,533],{},"Two identical scripts transform the same point from a national grid to a global system. The container with the transformation grid installed lands on the true position. The container without it falls back to a coarse seven-parameter transformation and lands about two metres away, reporting no error at all.",[44,535],{"x":46,"y":46,"width":47,"height":536,"fill":49},"258",[68,538,539],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"A missing grid file does not raise — it just moves your data",[44,541],{"x":542,"y":79,"width":543,"height":544,"rx":82,"fill":122,"stroke":123,"style":85},"20","344","180",[68,546,548],{"x":110,"y":547,"style":90,"fill":123,"textAnchor":74},"74","grid baked into the image",[550,551],"circle",{"cx":110,"cy":552,"r":553,"fill":97,"stroke":98,"style":99},"140","46",[550,555],{"cx":110,"cy":552,"r":96,"fill":123},[68,557,559],{"x":110,"y":143,"style":558,"fill":66,"textAnchor":74},"text-anchor:middle;font-size:10px;font-family:sans-serif","true position",[68,561,563],{"x":110,"y":562,"style":103,"fill":123,"textAnchor":74},"208","accurate to a few centimetres",[44,565],{"x":566,"y":79,"width":543,"height":544,"rx":82,"fill":152,"stroke":567,"style":85},"396","#b91c1c",[68,569,571],{"x":570,"y":547,"style":90,"fill":567,"textAnchor":74},"568","grid missing, network off",[550,573],{"cx":570,"cy":552,"r":553,"fill":97,"stroke":98,"style":99},[550,575],{"cx":570,"cy":552,"r":96,"fill":98},[550,577],{"cx":578,"cy":579,"r":96,"fill":567},"596","126",[166,581],{"x1":582,"y1":147,"x2":583,"y2":584,"stroke":567,"style":124},"574","590","129",[68,586,587],{"x":570,"y":143,"style":558,"fill":66,"textAnchor":74},"true",[68,589,591],{"x":590,"y":579,"style":558,"fill":567,"textAnchor":74},"640","computed",[68,593,594],{"x":570,"y":562,"style":103,"fill":567,"textAnchor":74},"about two metres out, exit code 0",[68,596,598],{"x":70,"y":597,"style":103,"fill":98,"textAnchor":74},"248","Assert the transformation you expect at start-up rather than trusting the default",[180,600,602],{"id":601},"composing-it-into-a-scheduled-job","Composing it into a scheduled job",[14,604,605,606,609],{},"For a job with a database and a few environment variables, a compose file is easier to read than a long ",[216,607,608],{},"docker run",":",[209,611,615],{"className":612,"code":613,"language":614,"meta":214,"style":214},"language-yaml shiki shiki-themes github-dark","services:\n  nightly-export:\n    image: nightly-export:3.34\n    volumes:\n      - \u002Fsrv\u002Fgis\u002Fdata:\u002Fdata:ro\n      - \u002Fsrv\u002Fgis\u002Foutput:\u002Fout\n      - qgis-profile:\u002Froot\u002F.local\u002Fshare\u002FQGIS\n    environment:\n      QGIS_AUTH_PASSWORD_FILE: \u002Frun\u002Fsecrets\u002Fqgis_master\n      PGHOST: db.example.org\n    secrets:\n      - qgis_master\nvolumes:\n  qgis-profile:\nsecrets:\n  qgis_master:\n    file: .\u002Fsecrets\u002Fqgis_master.txt\n","yaml",[216,616,617,627,634,645,652,660,667,674,681,691,701,708,715,722,730,738,746],{"__ignoreMap":214},[219,618,619,623],{"class":166,"line":221},[219,620,622],{"class":621},"s4JwU","services",[219,624,626],{"class":625},"s95oV",":\n",[219,628,629,632],{"class":166,"line":227},[219,630,631],{"class":621},"  nightly-export",[219,633,626],{"class":625},[219,635,636,639,642],{"class":166,"line":234},[219,637,638],{"class":621},"    image",[219,640,641],{"class":625},": ",[219,643,644],{"class":353},"nightly-export:3.34\n",[219,646,647,650],{"class":166,"line":240},[219,648,649],{"class":621},"    volumes",[219,651,626],{"class":625},[219,653,654,657],{"class":166,"line":246},[219,655,656],{"class":625},"      - ",[219,658,659],{"class":353},"\u002Fsrv\u002Fgis\u002Fdata:\u002Fdata:ro\n",[219,661,662,664],{"class":166,"line":252},[219,663,656],{"class":625},[219,665,666],{"class":353},"\u002Fsrv\u002Fgis\u002Foutput:\u002Fout\n",[219,668,669,671],{"class":166,"line":257},[219,670,656],{"class":625},[219,672,673],{"class":353},"qgis-profile:\u002Froot\u002F.local\u002Fshare\u002FQGIS\n",[219,675,676,679],{"class":166,"line":263},[219,677,678],{"class":621},"    environment",[219,680,626],{"class":625},[219,682,683,686,688],{"class":166,"line":269},[219,684,685],{"class":621},"      QGIS_AUTH_PASSWORD_FILE",[219,687,641],{"class":625},[219,689,690],{"class":353},"\u002Frun\u002Fsecrets\u002Fqgis_master\n",[219,692,693,696,698],{"class":166,"line":275},[219,694,695],{"class":621},"      PGHOST",[219,697,641],{"class":625},[219,699,700],{"class":353},"db.example.org\n",[219,702,703,706],{"class":166,"line":280},[219,704,705],{"class":621},"    secrets",[219,707,626],{"class":625},[219,709,710,712],{"class":166,"line":286},[219,711,656],{"class":625},[219,713,714],{"class":353},"qgis_master\n",[219,716,717,720],{"class":166,"line":291},[219,718,719],{"class":621},"volumes",[219,721,626],{"class":625},[219,723,725,728],{"class":166,"line":724},14,[219,726,727],{"class":621},"  qgis-profile",[219,729,626],{"class":625},[219,731,733,736],{"class":166,"line":732},15,[219,734,735],{"class":621},"secrets",[219,737,626],{"class":625},[219,739,741,744],{"class":166,"line":740},16,[219,742,743],{"class":621},"  qgis_master",[219,745,626],{"class":625},[219,747,749,752,754],{"class":166,"line":748},17,[219,750,751],{"class":621},"    file",[219,753,641],{"class":625},[219,755,756],{"class":353},".\u002Fsecrets\u002Fqgis_master.txt\n",[14,758,759,761,762,765,766,769,770,773,774,521],{},[191,760,299],{}," The named ",[216,763,764],{},"qgis-profile"," volume persists the QGIS profile — plugins, saved connections and the authentication database — across runs, so a credential stored once survives. The master password arrives as a mounted secret rather than an environment variable, which keeps it out of ",[216,767,768],{},"docker inspect",". Everything else is deliberately stateless: delete the container, keep the volume, and the next run behaves identically. The scheduler then only has to call ",[216,771,772],{},"docker compose run --rm nightly-export",", which is covered in ",[21,775,777],{"href":776},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fschedule-pyqgis-scripts-with-cron\u002F","Schedule PyQGIS Scripts with cron",[180,779,781],{"id":780},"order-the-dockerfile-so-rebuilds-are-fast","Order the Dockerfile so rebuilds are fast",[14,783,784],{},"Every instruction in a Dockerfile produces a layer, and Docker reuses cached layers until the first one whose inputs changed. Put the things that change rarely first and the thing that changes every commit last, and a rebuild after editing your script takes a second instead of two minutes.",[209,786,788],{"className":211,"code":787,"language":213,"meta":214,"style":214},"FROM qgis\u002Fqgis:release-3_34\n\nENV QT_QPA_PLATFORM=offscreen PYTHONUNBUFFERED=1\n\n# Changes rarely — cached across almost every build.\nRUN apt-get update && apt-get install -y --no-install-recommends curl \\\n    && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n\n# Changes when a dependency is added — cached while requirements.txt is untouched.\nWORKDIR \u002Fapp\nCOPY requirements.txt .\nRUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt\n\n# Changes on every commit — always rebuilt, but it is the cheapest layer.\nCOPY src\u002F \u002Fapp\u002Fsrc\u002F\n\nENTRYPOINT [\"python3\", \"\u002Fapp\u002Fsrc\u002Fnightly_export.py\"]\n",[216,789,790,794,798,803,807,812,817,822,826,831,835,839,843,847,852,857,861],{"__ignoreMap":214},[219,791,792],{"class":166,"line":221},[219,793,224],{},[219,795,796],{"class":166,"line":227},[219,797,231],{"emptyLinePlaceholder":230},[219,799,800],{"class":166,"line":234},[219,801,802],{},"ENV QT_QPA_PLATFORM=offscreen PYTHONUNBUFFERED=1\n",[219,804,805],{"class":166,"line":240},[219,806,231],{"emptyLinePlaceholder":230},[219,808,809],{"class":166,"line":246},[219,810,811],{},"# Changes rarely — cached across almost every build.\n",[219,813,814],{"class":166,"line":252},[219,815,816],{},"RUN apt-get update && apt-get install -y --no-install-recommends curl \\\n",[219,818,819],{"class":166,"line":257},[219,820,821],{},"    && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[219,823,824],{"class":166,"line":263},[219,825,231],{"emptyLinePlaceholder":230},[219,827,828],{"class":166,"line":269},[219,829,830],{},"# Changes when a dependency is added — cached while requirements.txt is untouched.\n",[219,832,833],{"class":166,"line":275},[219,834,260],{},[219,836,837],{"class":166,"line":280},[219,838,266],{},[219,840,841],{"class":166,"line":286},[219,842,272],{},[219,844,845],{"class":166,"line":291},[219,846,231],{"emptyLinePlaceholder":230},[219,848,849],{"class":166,"line":724},[219,850,851],{},"# Changes on every commit — always rebuilt, but it is the cheapest layer.\n",[219,853,854],{"class":166,"line":732},[219,855,856],{},"COPY src\u002F \u002Fapp\u002Fsrc\u002F\n",[219,858,859],{"class":166,"line":740},[219,860,231],{"emptyLinePlaceholder":230},[219,862,863],{"class":166,"line":748},[219,864,865],{},"ENTRYPOINT [\"python3\", \"\u002Fapp\u002Fsrc\u002Fnightly_export.py\"]\n",[14,867,868,870,871,874,875,878,879,882,883,886,887,890,891,894],{},[191,869,299],{}," Copying ",[216,872,873],{},"requirements.txt"," on its own, before the source, is the key move: editing the script invalidates only the final ",[216,876,877],{},"COPY",", while the expensive ",[216,880,881],{},"pip install"," layer stays cached. Combining ",[216,884,885],{},"apt-get update"," with the install and the cleanup in a single ",[216,888,889],{},"RUN"," matters for size as well as correctness — separate instructions would leave the package lists inside an earlier layer where deleting them later cannot reclaim the space. ",[216,892,893],{},"--no-install-recommends"," typically saves a few hundred megabytes of packages nothing in the job uses.",[14,896,897,898,901,902,905,906,908,909,911],{},"Two habits complete the picture. Add a ",[216,899,900],{},".dockerignore"," listing ",[216,903,904],{},".git",", test data and any local virtual environment, because everything else in the directory is sent to the daemon as build context on every build. And pin what you can: a base image tag rather than ",[216,907,311],{},", and version specifiers in ",[216,910,873],{},", so the image you build in six months is the image you are running today.",[14,913,914],{},[29,915,918,921,924,927,930,935,939,944,948,950,954,957,961,965,968,972,975,977,979,982,984,987],{"viewBox":916,"role":32,"ariaLabel":917,"xmlns":34},"0 0 760 244","Two Dockerfile orderings compared: copying the source before installing dependencies rebuilds everything on each commit, while copying requirements first keeps the install layer cached",[36,919,920],{},"Layer order decides how long a rebuild takes",[40,922,923],{},"In the first ordering the source is copied before the dependencies are installed, so editing one line of the script invalidates the install layer and the rebuild reinstalls everything. In the second ordering the requirements file is copied and installed first, so a source edit invalidates only the last, cheapest layer.",[44,925],{"x":46,"y":46,"width":47,"height":926,"fill":49},"244",[68,928,929],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"Same instructions, two very different rebuild times",[44,931],{"x":932,"y":79,"width":933,"height":934,"rx":82,"fill":152,"stroke":153,"style":85},"16","356","176",[68,936,938],{"x":937,"y":89,"style":90,"fill":153,"textAnchor":74},"194","source copied first",[44,940],{"x":941,"y":942,"width":943,"height":71,"rx":59,"fill":97,"stroke":98,"style":99},"40","88","308",[68,945,947],{"x":937,"y":946,"style":103,"fill":66,"textAnchor":74},"107","FROM qgis\u002Fqgis — cached",[44,949],{"x":941,"y":143,"width":943,"height":71,"rx":59,"fill":97,"stroke":567,"style":124},[68,951,953],{"x":937,"y":952,"style":103,"fill":567,"textAnchor":74},"139","COPY src\u002F — invalidated",[44,955],{"x":941,"y":956,"width":943,"height":71,"rx":59,"fill":97,"stroke":567,"style":124},"152",[68,958,960],{"x":937,"y":959,"style":103,"fill":567,"textAnchor":74},"171","pip install — rebuilt every time",[68,962,964],{"x":937,"y":963,"style":103,"fill":153,"textAnchor":74},"204","two minutes for a one-line edit",[44,966],{"x":967,"y":79,"width":933,"height":934,"rx":82,"fill":122,"stroke":123,"style":85},"388",[68,969,971],{"x":970,"y":89,"style":90,"fill":123,"textAnchor":74},"566","requirements copied first",[44,973],{"x":974,"y":942,"width":943,"height":71,"rx":59,"fill":97,"stroke":98,"style":99},"412",[68,976,947],{"x":970,"y":946,"style":103,"fill":66,"textAnchor":74},[44,978],{"x":974,"y":143,"width":943,"height":71,"rx":59,"fill":97,"stroke":98,"style":99},[68,980,981],{"x":970,"y":952,"style":103,"fill":66,"textAnchor":74},"pip install — cached",[44,983],{"x":974,"y":956,"width":943,"height":71,"rx":59,"fill":97,"stroke":123,"style":124},[68,985,986],{"x":970,"y":959,"style":103,"fill":123,"textAnchor":74},"COPY src\u002F — the only rebuild",[68,988,989],{"x":970,"y":963,"style":103,"fill":123,"textAnchor":74},"about a second",[180,991,993],{"id":992},"qgis-version-compatibility","QGIS version compatibility",[14,995,996,997,1000],{},"The examples target ",[191,998,999],{},"QGIS 3.34 LTR"," in the official image.",[1002,1003,1004,1020],"table",{},[1005,1006,1007],"thead",{},[1008,1009,1010,1014,1017],"tr",{},[1011,1012,1013],"th",{},"Image tag",[1011,1015,1016],{},"QGIS",[1011,1018,1019],{},"Notes",[1021,1022,1023,1038,1049,1060],"tbody",{},[1008,1024,1025,1029,1032],{},[1026,1027,1028],"td",{},"qgis\u002Fqgis:release-3_28",[1026,1030,1031],{},"3.28 LTR",[1026,1033,1034,1035,1037],{},"Python 3.9; ",[216,1036,323],{}," not needed.",[1008,1039,1040,1043,1046],{},[1026,1041,1042],{},"qgis\u002Fqgis:release-3_34",[1026,1044,1045],{},"3.34 LTR",[1026,1047,1048],{},"Baseline for this page.",[1008,1050,1051,1054,1057],{},[1026,1052,1053],{},"qgis\u002Fqgis:release-3_40",[1026,1055,1056],{},"3.40",[1026,1058,1059],{},"Newer GDAL and PROJ; re-verify any national grid transformations.",[1008,1061,1062,1065,1068],{},[1026,1063,1064],{},"qgis\u002Fqgis:latest",[1026,1066,1067],{},"development",[1026,1069,1070],{},"Rebuilt continuously — never pin production to it.",[14,1072,1073,1074,1076,1077,1080],{},"The ",[216,1075,303],{}," images target testing and include development tooling. For a slimmer production image, install ",[216,1078,1079],{},"qgis-python"," from the QGIS repository onto a plain distribution base and skip the desktop package entirely.",[180,1082,1084],{"id":1083},"troubleshooting","Troubleshooting",[185,1086,1087,1096,1104,1113,1126,1136],{},[188,1088,1089,1092,1093,1095],{},[191,1090,1091],{},"Output files are owned by root."," Add ",[216,1094,470],{},", and make sure the mounted output directory is writable by that user.",[188,1097,1098,459,1101,1103],{},[191,1099,1100],{},"\"could not connect to display\".",[216,1102,315],{}," is missing, or something imported Qt before it was set.",[188,1105,1106,459,1109,1112],{},[191,1107,1108],{},"\"algorithm not found\".",[216,1110,1111],{},"Processing.initialize()"," was not called, or a provider plugin is not in the profile the container is using.",[188,1114,1115,1118,1119,1121,1122,1125],{},[191,1116,1117],{},"The image is enormous."," The QGIS desktop package pulls in a large dependency tree. Install only ",[216,1120,1079],{}," and ",[216,1123,1124],{},"python3-qgis"," for a leaner image, or use a multi-stage build.",[188,1127,1128,1131,1132,1135],{},[191,1129,1130],{},"The container cannot resolve the database host."," It has its own network namespace; use the compose network, or ",[216,1133,1134],{},"--network host"," on Linux when the database is reached through the host's own routes.",[188,1137,1138,1141,1142,1145],{},[191,1139,1140],{},"A reprojection differs from the desktop result."," The grid files differ. Print ",[216,1143,1144],{},"pyproj","'s or PROJ's data directory contents in both environments and compare.",[180,1147,1149],{"id":1148},"conclusion","Conclusion",[14,1151,1152],{},"A PyQGIS container is a pinned base image, your dependencies, your script and an offscreen Qt platform — plus two mounts and a user id so the results land on the host with the right ownership. Pin the QGIS tag, bake in the transformation grids you rely on, persist the profile in a named volume when credentials are involved, and the job you run tonight will produce the same numbers in a year.",[180,1154,1156],{"id":1155},"frequently-asked-questions","Frequently Asked Questions",[14,1158,1159,1162],{},[191,1160,1161],{},"Can I run the QGIS desktop GUI from the container?","\nYes, by forwarding X11 or using a VNC-enabled image, but that is a debugging convenience rather than a deployment strategy. Automated jobs should stay offscreen.",[14,1164,1165,1168,1169,1171],{},[191,1166,1167],{},"Do I need the full QGIS package?","\nNo. ",[216,1170,1124],{}," and its dependencies are enough for scripting and rendering. Dropping the desktop package roughly halves the image.",[14,1173,1174,1177,1178,1181,1182,521],{},[191,1175,1176],{},"How do I install a plugin into the image?","\nCopy it into the profile's ",[216,1179,1180],{},"python\u002Fplugins"," directory during the build and enable it in the profile's settings file — or, better, install the plugin's algorithms as a Processing provider your script registers directly, as described in ",[21,1183,1185],{"href":1184},"\u002Fqgis-plugin-development\u002Fprocessing-provider-plugins\u002F","Processing Provider Plugins",[14,1187,1188,1191],{},[191,1189,1190],{},"Is Podman a drop-in replacement?","\nFor these files, yes. Rootless Podman also removes the file-ownership problem, since container root maps to your user on the host.",[14,1193,1194,1197],{},[191,1195,1196],{},"How do I test the image before scheduling it?","\nRun it once by hand with the real mounts and a small input, and check both the output and the exit code. A job that has never been run manually should not be given to a scheduler.",[180,1199,1201],{"id":1200},"related","Related",[185,1203,1204,1209,1213,1219,1225],{},[188,1205,1206,1208],{},[21,1207,24],{"href":23}," — the guide this recipe belongs to",[188,1210,1211],{},[21,1212,777],{"href":776},[188,1214,1215],{},[21,1216,1218],{"href":1217},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fuse-qgis-process-command-line-runner\u002F","Use the qgis_process Command-Line Runner",[188,1220,1221],{},[21,1222,1224],{"href":1223},"\u002Fpyqgis-fundamentals-environment-setup\u002Fvirtual-environments-for-gis\u002Frunning-python-scripts-outside-qgis-desktop\u002F","Running Python Scripts Outside QGIS Desktop",[188,1226,1227],{},[21,1228,1230],{"href":1229},"\u002Fpyqgis-fundamentals-environment-setup\u002Fvirtual-environments-for-gis\u002F","Virtual Environments for GIS",[1232,1233,1234],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}",{"title":214,"searchDepth":227,"depth":227,"links":1236},[1237,1238,1239,1240,1241,1242,1243,1244,1245,1246,1247],{"id":182,"depth":227,"text":183},{"id":206,"depth":227,"text":207},{"id":336,"depth":227,"text":337},{"id":478,"depth":227,"text":479},{"id":601,"depth":227,"text":602},{"id":780,"depth":227,"text":781},{"id":992,"depth":227,"text":993},{"id":1083,"depth":227,"text":1084},{"id":1148,"depth":227,"text":1149},{"id":1155,"depth":227,"text":1156},{"id":1200,"depth":227,"text":1201},"Build a reproducible headless PyQGIS image, mount data and scripts, pin QGIS, GDAL and PROJ versions, and run the container from a scheduler without a display.","md",{"slug":1251,"type":1252,"breadcrumb":1253,"datePublished":1254,"dateModified":1254},"run-pyqgis-in-docker-container","article","PyQGIS in Docker","2026-08-10","\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Frun-pyqgis-in-docker-container",{"title":5,"description":1248},"pyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Frun-pyqgis-in-docker-container\u002Findex","ffj9ruwpeNSLq5246a5zXEW3H7oagEh777ZFDL05zfA",1786401337551]