{"id":1442,"date":"2026-10-25T09:01:50","date_gmt":"2026-10-25T09:01:50","guid":{"rendered":"https:\/\/helpwithmygisassignment.com\/blog\/?p=1442"},"modified":"2026-10-25T09:01:50","modified_gmt":"2026-10-25T09:01:50","slug":"containerizing-geospatial-pipelines-docker","status":"publish","type":"post","link":"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/","title":{"rendered":"Containerizing Geospatial Pipelines with Docker: Bundling GDAL, QGIS Scripts, and Python"},"content":{"rendered":"<div><\/div>\n<div>\n<div id=\"ez-toc-container\" class=\"ez-toc-v2_0_89 counter-hierarchy ez-toc-counter ez-toc-grey ez-toc-container-direction\">\n<div class=\"ez-toc-title-container\">\n<p class=\"ez-toc-title\" style=\"cursor:inherit\">Table of Contents<\/p>\n<span class=\"ez-toc-title-toggle\"><a href=\"#\" class=\"ez-toc-pull-right ez-toc-btn ez-toc-btn-xs ez-toc-btn-default ez-toc-toggle\" aria-label=\"Toggle Table of Content\"><span class=\"ez-toc-js-icon-con\"><span class=\"\"><span class=\"eztoc-hide\" style=\"display:none;\">Toggle<\/span><span class=\"ez-toc-icon-toggle-span\"><svg style=\"fill: #999;color:#999\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" class=\"list-377408\" width=\"20px\" height=\"20px\" viewBox=\"0 0 24 24\" fill=\"none\"><path d=\"M6 6H4v2h2V6zm14 0H8v2h12V6zM4 11h2v2H4v-2zm16 0H8v2h12v-2zM4 16h2v2H4v-2zm16 0H8v2h12v-2z\" fill=\"currentColor\"><\/path><\/svg><svg style=\"fill: #999;color:#999\" class=\"arrow-unsorted-368013\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"10px\" height=\"10px\" viewBox=\"0 0 24 24\" version=\"1.2\" baseProfile=\"tiny\"><path d=\"M18.2 9.3l-6.2-6.3-6.2 6.3c-.2.2-.3.4-.3.7s.1.5.3.7c.2.2.4.3.7.3h11c.3 0 .5-.1.7-.3.2-.2.3-.5.3-.7s-.1-.5-.3-.7zM5.8 14.7l6.2 6.3 6.2-6.3c.2-.2.3-.5.3-.7s-.1-.5-.3-.7c-.2-.2-.4-.3-.7-.3h-11c-.3 0-.5.1-.7.3-.2.2-.3.5-.3.7s.1.5.3.7z\"\/><\/svg><\/span><\/span><\/span><\/a><\/span><\/div>\n<nav><ul class='ez-toc-list ez-toc-list-level-1 ' ><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-1\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Containerizing_Geospatial_Pipelines_with_Docker_Bundling_GDAL_QGIS_Scripts_and_Python\" >Containerizing Geospatial Pipelines with Docker: Bundling GDAL, QGIS Scripts, and Python<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-2\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#1_Introduction_Containerizing_Geospatial_Pipelines_Docker\" >1. Introduction: Containerizing Geospatial Pipelines Docker<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-3\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#2_Choose_the_Right_Base_Image\" >2. Choose the Right Base Image<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-4\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#3_Step_1_%E2%80%94_Write_a_Headless_Python_Spatial_Processing_Script\" >3. Step 1 \u2014 Write a Headless Python Spatial Processing Script<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-5\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Organize_the_project\" >Organize the project<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-6\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Create_srcgeoworkflowreprojectpy\" >Create src\/geoworkflow\/reproject.py<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-7\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#4_Step_2_%E2%80%94_Build_the_Dockerfile\" >4. Step 2 \u2014 Build the Dockerfile<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-8\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Create_Dockerfile\" >Create Dockerfile<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-9\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Why_the_Dockerfile_is_structured_this_way\" >Why the Dockerfile is structured this way<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-10\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Add_dockerignore\" >Add .dockerignore<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-11\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Build_the_image\" >Build the image<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-12\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Verify_the_runtime\" >Verify the runtime<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-13\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#5_Step_3_%E2%80%94_Mount_Local_Directories_into_the_Container\" >5. Step 3 \u2014 Mount Local Directories into the Container<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-14\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#How_the_mounted_volume_works\" >How the mounted volume works<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-15\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Override_the_target_CRS\" >Override the target CRS<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-16\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#6_Step_4_%E2%80%94_Run_PyQGIS_in_a_Docker_Container\" >6. Step 4 \u2014 Run PyQGIS in a Docker Container<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-17\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Minimal_QGIS_Dockerfile\" >Minimal QGIS Dockerfile<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-18\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Example_headless_PyQGIS_script\" >Example headless PyQGIS script<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-19\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#7_Step_5_%E2%80%94_Orchestrate_Containers_for_Automated_Batch_Jobs\" >7. Step 5 \u2014 Orchestrate Containers for Automated Batch Jobs<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-20\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#8_Lightweight_Geospatial_Containers_Best_Practices\" >8. Lightweight Geospatial Containers: Best Practices<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-21\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Keep_the_runtime_minimal\" >Keep the runtime minimal<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-22\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Pin_and_test_the_complete_spatial_stack\" >Pin and test the complete spatial stack<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-23\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Keep_data_and_code_separate\" >Keep data and code separate<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-24\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Validate_inputs_and_outputs\" >Validate inputs and outputs<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-25\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Treat_logging_as_an_operational_requirement\" >Treat logging as an operational requirement<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-26\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#Automate_builds_and_tests\" >Automate builds and tests<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-27\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/#9_Summary\" >9. Summary<\/a><\/li><\/ul><\/li><\/ul><\/nav><\/div>\n<h1><span class=\"ez-toc-section\" id=\"Containerizing_Geospatial_Pipelines_with_Docker_Bundling_GDAL_QGIS_Scripts_and_Python\"><\/span>Containerizing Geospatial Pipelines with Docker: Bundling GDAL, QGIS Scripts, and Python<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<h2><span class=\"ez-toc-section\" id=\"1_Introduction_Containerizing_Geospatial_Pipelines_Docker\"><\/span>1. Introduction: Containerizing Geospatial Pipelines Docker<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-large wp-image-1597\" src=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_l1ozvql1ozvql1oz-1024x559.jpg\" alt=\"Containerizing Geospatial Pipelines Docker\" width=\"1024\" height=\"559\" srcset=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_l1ozvql1ozvql1oz-1024x559.jpg 1024w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_l1ozvql1ozvql1oz-300x164.jpg 300w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_l1ozvql1ozvql1oz-768x419.jpg 768w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_l1ozvql1ozvql1oz.jpg 1408w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/p>\n<p>When Containerizing Geospatial Pipelines<a href=\"https:\/\/docs.docker.com\/\" target=\"_blank\" rel=\"noopener\"> Docke<\/a>r architectures efficiently, developers often struggle with native dependency management. Geospatial <a href=\"https:\/\/helpwithmygisassignment.com\/blog\/building-real-time-spatial-dashboards-in-qgis-with-geonode-and-postgis\/\">Python<\/a> applications depend on more than Python packages. A seemingly simple script that clips a raster, reprojects vector data, or processes satellite imagery may rely on several native libraries working together correctly.<\/p>\n<p><a href=\"https:\/\/gdal.org\/\" target=\"_blank\" rel=\"noopener\">GDAL<\/a> provides access to a wide range of raster and vector formats. PROJ handles coordinate reference system transformations, while GEOS provides geometry operations used by libraries such as Shapely. Python packages such as Rasterio, Fiona, GeoPandas, and PyProj connect these capabilities to application code. QGIS adds its own processing framework and PyQGIS bindings, which must remain compatible with the QGIS installation and its underlying libraries.<\/p>\n<p>These dependencies create a common deployment problem: a script that works on one computer may fail on another because the native libraries, Python packages, or environment variables differ.<\/p>\n<p>For example, a developer might install one version of GDAL through the operating system, another through Conda, and a Python package that expects a different GDAL API. A coordinate transformation might also behave differently if PROJ&#8217;s database or grid resources are missing. Updating one package can break several others.<\/p>\n<p>Docker addresses this problem by packaging the geospatial runtime into a versioned container image. The image can contain the operating-system dependencies, native spatial libraries, Python packages, application scripts, and configuration required to run a processing task. The same image can then be executed on a developer&#8217;s workstation, a build server, or a compatible cloud host.<\/p>\n<p>A container does not automatically make every environment identical: host architecture, mounted files, permissions, external data sources, and runtime configuration still matter. Nevertheless, using pinned dependencies and a controlled container image makes the processing environment much more reproducible.<\/p>\n<p>This guide builds a practical Docker workflow for geospatial processing, starting with a headless Python script and progressing to a reusable Dockerfile, mounted input\/output directories, batch execution, and the additional configuration needed to run QGIS processing scripts inside a container.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"2_Choose_the_Right_Base_Image\"><\/span>2. Choose the Right Base Image<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>The base image determines which native libraries, Python runtime, command-line utilities, and spatial drivers are available inside the container.<\/p>\n<p>Three approaches are common.<\/p>\n<div>\n<div>\n<table>\n<tbody>\n<tr>\n<th>Base image strategy<\/th>\n<th>Best use<\/th>\n<th>Main consideration<\/th>\n<\/tr>\n<tr>\n<td><code>ghcr.io\/osgeo\/gdal<\/code><\/td>\n<td>GDAL\/OGR operations and Python bindings<\/td>\n<td>Choose a configuration with the required drivers<\/td>\n<\/tr>\n<tr>\n<td><code>python:slim<\/code><\/td>\n<td>Lightweight Python applications using compatible precompiled spatial wheels<\/td>\n<td>Native libraries and Python bindings need deliberate compatibility management<\/td>\n<\/tr>\n<tr>\n<td><code>qgis\/qgis<\/code><\/td>\n<td>Scripts using PyQGIS and the QGIS Processing framework<\/td>\n<td>Larger image and QGIS-specific runtime configuration<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>The GDAL project publishes tagged container images through GitHub Container Registry, with configurations intended for different combinations of libraries and drivers. Its download documentation identifies the official container configurations and the current GDAL releases. \u3010turn817664view0\u3011<\/p>\n<p>For this guide, the main example uses a version-specific GDAL image:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>FROM ghcr.io\/osgeo\/gdal:ubuntu-small-3.13.3<\/code><\/pre>\n<\/div>\n<\/div>\n<p>This tag illustrates the version-pinned approach; confirm that the exact tag and platform are available in the official registry before building. The <code>small<\/code> configuration may not contain every optional format driver, so use a more feature-complete configuration if your workflow depends on formats such as particular scientific raster or remote-sensing formats.<\/p>\n<p>Important: Avoid installing an arbitrary PyPI <code>GDAL<\/code> version over the version supplied by the base image. GDAL&#8217;s Python bindings depend on the underlying native GDAL library, and NumPy compatibility can also affect compiled extensions. \u3010turn814146view3\u3011<\/p>\n<p>If you require GeoPandas, Rasterio, Shapely, and GDAL Python bindings together, choose a compatible package set and test it as one environment. A Conda-forge environment or a deliberately maintained compatible image is preferable to indiscriminately mixing native packages from different sources.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"3_Step_1_%E2%80%94_Write_a_Headless_Python_Spatial_Processing_Script\"><\/span>3. Step 1 \u2014 Write a Headless Python Spatial Processing Script<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>We will build a batch raster reprojection pipeline.<\/p>\n<p>The script will:<\/p>\n<ol>\n<li>\n<div>\n<div>\n<p>Read GeoTIFF files from an input directory.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Validate that each raster can be opened and has a defined coordinate reference system.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Reproject each raster using the GDAL Python API.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Write tiled, compressed GeoTIFF outputs into a separate directory.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Log individual failures and return a nonzero exit code if processing fails for any file.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<\/ol>\n<p>Unlike a desktop GIS workflow, the script does not need to open a graphical interface. This makes it appropriate for Docker, scheduled jobs, and continuous integration.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Organize_the_project\"><\/span>Organize the project<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Create the following directory structure:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>geo-pipeline\/\r\n\u251c\u2500\u2500 Dockerfile\r\n\u251c\u2500\u2500 requirements.txt\r\n\u251c\u2500\u2500 .dockerignore\r\n\u251c\u2500\u2500 src\/\r\n\u2502   \u2514\u2500\u2500 geoworkflow\/\r\n\u2502       \u251c\u2500\u2500 __init__.py\r\n\u2502       \u2514\u2500\u2500 reproject.py\r\n\u2514\u2500\u2500 data\/\r\n    \u251c\u2500\u2500 input\/\r\n    \u2514\u2500\u2500 output\/<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The <code>src\/geoworkflow\/<\/code> directory contains application code, while the <code>data\/<\/code> directory holds local processing data. The data directory will be mounted at runtime rather than copied into the image.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Create_srcgeoworkflowreprojectpy\"><\/span>Create <code>src\/geoworkflow\/reproject.py<\/code><span class=\"ez-toc-section-end\"><\/span><\/h3>\n<div>\n<div>\n<pre dir=\"ltr\"><code>\"\"\"Batch raster reprojection with GDAL.Input:  GeoTIFF files in an input directory.Output: Reprojected GeoTIFF files in an output directory.\"\"\"import argparseimport loggingimport osimport tempfilefrom pathlib import Pathfrom osgeo import gdal# Make GDAL raise Python exceptions for processing failures.gdal.UseExceptions()logging.basicConfig(    level=logging.INFO,    format=\"%(asctime)s | %(levelname)s | %(message)s\",)logger = logging.getLogger(\"geoworkflow\")def reproject_raster(    source_path: Path,    output_path: Path,    target_crs: str,    overwrite: bool = False,) -&gt; None:    \"\"\"Reproject one raster and write a GeoTIFF result.\"\"\"    if output_path.exists() and not overwrite:        raise FileExistsError(            f\"Output already exists: {output_path}\"        )    # Open the source raster in read-only mode.    source = gdal.Open(str(source_path), gdal.GA_ReadOnly)    if source is None:        raise RuntimeError(            f\"GDAL could not open {source_path}\"        )    try:        # Refuse to guess the source coordinate reference system.        if not source.GetProjection():            raise ValueError(                f\"Source raster has no CRS: {source_path}\"            )        # Create a temporary output in the destination directory.        # Keeping it in the same directory permits an atomic rename        # on common local filesystems after a successful write.        with tempfile.NamedTemporaryFile(            dir=output_path.parent,            suffix=\".tif\",            prefix=f\".{output_path.stem}_\",            delete=False,        ) as temp_file:            temporary_path = Path(temp_file.name)        try:            # Warp to the requested CRS, with GeoTIFF compression.            options = gdal.WarpOptions(                format=\"GTiff\",                dstSRS=target_crs,                creationOptions=[                    \"TILED=YES\",                    \"COMPRESS=DEFLATE\",                ],                multithread=True,            )            result = gdal.Warp(                str(temporary_path),                source,                options=options,            )            if result is None:                raise RuntimeError(                    f\"GDAL reprojection failed: {source_path}\"                )            # Flush output and close the file before replacing.            result.FlushCache()            result = None            if not Path(temporary_path).exists():                raise RuntimeError(                    \"GDAL did not create the output file.\"                )            if not overwrite and output_path.exists():                raise FileExistsError(output_path)            os.replace(temporary_path, output_path)            logger.info(                \"Reprojected %s -&gt; %s\",                source_path.name,                output_path.name,            )        finally:            # Remove incomplete temporary output after a failure.            if temporary_path.exists():                temporary_path.unlink()    finally:        # Release the GDAL dataset handle.        source = Nonedef main() -&gt; int:    \"\"\"Parse arguments and process every matching raster.\"\"\"    parser = argparse.ArgumentParser(        description=\"Batch-reproject GeoTIFF files using GDAL.\"    )    parser.add_argument(        \"--input-dir\",        type=Path,        default=Path(\"\/data\/input\"),        help=\"Directory containing source GeoTIFF files.\",    )    parser.add_argument(        \"--output-dir\",        type=Path,        default=Path(\"\/data\/output\"),        help=\"Directory for reprojected rasters.\",    )    parser.add_argument(        \"--dst-crs\",        required=True,        help=\"Target CRS, for example EPSG:4326.\",    )    parser.add_argument(        \"--overwrite\",        action=\"store_true\",        help=\"Replace existing output files.\",    )    args = parser.parse_args()    if not args.input_dir.is_dir():        logger.error(            \"Input directory does not exist: %s\",            args.input_dir,        )        return 2    args.output_dir.mkdir(parents=True, exist_ok=True)    rasters = sorted(        path        for path in args.input_dir.iterdir()        if path.is_file()        and path.suffix.lower() in {\".tif\", \".tiff\"}    )    if not rasters:        logger.error(            \"No GeoTIFF files found in %s\",            args.input_dir,        )        return 2    failures = []    for source_path in rasters:        output_path = (            args.output_dir \/ f\"{source_path.stem}_reprojected.tif\"        )        try:            reproject_raster(                source_path=source_path,                output_path=output_path,                target_crs=args.dst_crs,                overwrite=args.overwrite,            )        except Exception as exc:            logger.exception(                \"Failed to process %s: %s\",                source_path.name,                exc,            )            failures.append(source_path.name)    logger.info(        \"Finished batch: %d total, %d failed.\",        len(rasters),        len(failures),    )    if failures:        logger.error(\"Failed files: %s\", \", \".join(failures))        return 1    return 0if __name__ == \"__main__\":    raise SystemExit(main())<\/code><\/pre>\n<\/div>\n<div><\/div>\n<\/div>\n<p>Create an empty <code>src\/geoworkflow\/__init__.py<\/code> file so Python can import the module.<\/p>\n<p>The script deliberately requires a target CRS and refuses to process rasters whose source CRS is missing. This prevents a common spatial data error: transforming coordinates from an unknown source reference system.<\/p>\n<p>It also uses temporary outputs so that a failed transformation does not normally leave a partial file under the final output name. For workflows involving interrupted jobs, network filesystems, or very large files, implement additional cleanup and recovery checks suited to the storage environment.<\/p>\n<p>CRS note: <code>EPSG:4326<\/code> is used in the examples below. Choose an appropriate target CRS for your application, and verify that the source data has correctly defined spatial metadata. Reprojection changes the coordinate representation; it does not automatically improve the accuracy of the input data.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"4_Step_2_%E2%80%94_Build_the_Dockerfile\"><\/span>4. Step 2 \u2014 Build the Dockerfile<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Now package the GDAL runtime and custom Python code into an image.<\/p>\n<p>Create a file named <code>requirements.txt<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code># Add application-level dependencies here and pin tested versions.\r\n#\r\n# The base image supplies GDAL and its Python bindings.\r\n# Do not install an unrelated GDAL version from PyPI.<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The file is intentionally a template because the demonstrated script depends only on Python&#8217;s standard library and the GDAL Python bindings from the base image. When you introduce additional libraries, add them here only after confirming compatibility with the spatial runtime.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Create_Dockerfile\"><\/span>Create <code>Dockerfile<\/code><span class=\"ez-toc-section-end\"><\/span><\/h3>\n<div>\n<div>\n<pre dir=\"ltr\"><code># Use a version-specific GDAL image.\r\n# Verify the selected tag in the official registry.\r\nFROM ghcr.io\/osgeo\/gdal:ubuntu-small-3.13.3\r\n\r\n# Make Python logs appear immediately in container output.\r\nENV PYTHONDONTWRITEBYTECODE=1\r\nENV PYTHONUNBUFFERED=1\r\n\r\n# Install venv support while retaining the image's system\r\n# Python packages and matching GDAL bindings.\r\nRUN apt-get update \\\r\n    &amp;&amp; apt-get install -y --no-install-recommends \\\r\n        python3-venv \\\r\n    &amp;&amp; rm -rf \/var\/lib\/apt\/lists\/*\r\n\r\n# Use the same system interpreter as the GDAL bindings.\r\nRUN python3 -m venv --system-site-packages \/opt\/venv\r\n\r\nENV PATH=\"\/opt\/venv\/bin:${PATH}\"\r\n\r\nWORKDIR \/opt\/app\r\n\r\n# Copy dependency definitions separately to preserve build caching.\r\nCOPY requirements.txt \/opt\/app\/requirements.txt\r\n\r\n# Install application dependencies without retaining pip caches.\r\nRUN pip install --no-cache-dir -r requirements.txt\r\n\r\n# Copy the application and supporting Python modules.\r\nCOPY src\/ \/opt\/app\/src\/\r\n\r\nENV PYTHONPATH=\"\/opt\/app\/src\"\r\n\r\n# The container runs the pipeline by default.\r\nENTRYPOINT [\"python\", \"-m\", \"geoworkflow.reproject\"]\r\n\r\n# Default arguments can be overridden when the container runs.\r\nCMD [\r\n    \"--input-dir\", \"\/data\/input\",\r\n    \"--output-dir\", \"\/data\/output\",\r\n    \"--dst-crs\", \"EPSG:4326\"\r\n]<\/code><\/pre>\n<\/div>\n<\/div>\n<h3><span class=\"ez-toc-section\" id=\"Why_the_Dockerfile_is_structured_this_way\"><\/span>Why the Dockerfile is structured this way<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>The spatial runtime comes first. GDAL and its compatible Python bindings are inherited from the base image rather than rebuilt separately using pip.<\/p>\n<p>The Python environment is deliberate. The virtual environment uses <code>--system-site-packages<\/code>, allowing the Python process to access the system&#8217;s GDAL bindings. Third-party packages installed later should not replace or upgrade those spatial dependencies without a compatibility test.<\/p>\n<p>Dependencies are copied before source code. Docker can reuse the dependency-installation layer when the Python application changes but <code>requirements.txt<\/code> remains unchanged. Docker recommends separating build stages where appropriate, using trusted and appropriately sized base images, and keeping image layers efficient. \u3010turn814146view2\u3011<\/p>\n<p>The entrypoint makes the image reusable. The image runs the same processing module each time, while arguments such as input directory, output directory, and target CRS can change without rebuilding the image.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Add_dockerignore\"><\/span>Add <code>.dockerignore<\/code><span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Keep unnecessary files out of the build context.<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>.git\/\r\n.venv\/\r\n__pycache__\/\r\n*.py[cod]\r\n\r\ndata\/\r\noutput\/\r\n*.tif\r\n*.tiff\r\n*.gpkg\r\n*.shp\r\n*.shx\r\n*.dbf\r\n*.prj<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Excluding source datasets is particularly important. You generally want the image to contain the processing application, not a copy of every raster or shapefile that it will process.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Build_the_image\"><\/span>Build the image<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>From the project root, run:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>docker build --pull -t geo-pipeline:gdal-3.13.3 .<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The <code>--pull<\/code> option checks for an updated copy of the base image. For reproducible production releases, use a maintained, version-specific base reference and consider pinning its digest as well. Updating a base image should be a deliberate rebuild-and-test event, rather than an uncontrolled change during a routine run. \u3010turn814146view2\u3011<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Verify_the_runtime\"><\/span>Verify the runtime<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Check that GDAL is available:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>docker run --rm \\\r\n  --entrypoint gdalinfo \\\r\n  geo-pipeline:gdal-3.13.3 \\\r\n  --version<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Check the Python bindings:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>docker run --rm \\\r\n  --entrypoint python \\\r\n  geo-pipeline:gdal-3.13.3 \\\r\n  -c \"from osgeo import gdal; print(gdal.VersionInfo('RELEASE_NAME'))\"<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Both checks should complete successfully. A failure at this stage should be fixed before introducing real input datasets.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"5_Step_3_%E2%80%94_Mount_Local_Directories_into_the_Container\"><\/span>5. Step 3 \u2014 Mount Local Directories into the Container<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-large wp-image-1598\" src=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_6i7ae16i7ae16i7a-1024x559.jpg\" alt=\"Diagram showing local directory volume mounting into a Docker container for geospatial pipelines\" width=\"1024\" height=\"559\" srcset=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_6i7ae16i7ae16i7a-1024x559.jpg 1024w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_6i7ae16i7ae16i7a-300x164.jpg 300w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_6i7ae16i7ae16i7a-768x419.jpg 768w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_6i7ae16i7ae16i7a.jpg 1408w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/p>\n<p>A container image contains the software; mounted volumes provide the data.<\/p>\n<p>This separation is essential in geospatial workflows because raster datasets, satellite imagery, and large vector collections may be hundreds of megabytes or several gigabytes. Copying them into an image would increase its size and require rebuilding whenever the data changed.<\/p>\n<p>Place a few test GeoTIFF files in <code>data\/input\/<\/code>. Create <code>data\/output\/<\/code> if it does not already exist.<\/p>\n<p>Run the container:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>docker run --rm \\\r\n  --mount type=bind,source=\"$(pwd)\/data\",target=\/data \\\r\n  geo-pipeline:gdal-3.13.3 \\\r\n  --input-dir \/data\/input \\\r\n  --output-dir \/data\/output \\\r\n  --dst-crs EPSG:4326<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The command maps the host&#8217;s <code>data\/<\/code> directory to <code>\/data\/<\/code> inside the container. The script reads from <code>\/data\/input<\/code> and writes to <code>\/data\/output<\/code>.<\/p>\n<div>\n<h3><span class=\"ez-toc-section\" id=\"How_the_mounted_volume_works\"><\/span>How the mounted volume works<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<div>\n<div>\n<p>Host machine<\/p>\n<\/div>\n<p>geo-pipeline\/data\/input\/*.tif<\/p>\n<p>geo-pipeline\/data\/output\/<\/p>\n<\/div>\n<div>\n<p>Bind mount: host data \u2194 \/data<\/p>\n<\/div>\n<div>\n<div>\n<p>Docker container<\/p>\n<\/div>\n<p>Reads \/data\/input<\/p>\n<p>Writes \/data\/output<\/p>\n<p>Runs GDAL through Python<\/p>\n<\/div>\n<\/div>\n<p>The <code>--rm<\/code> flag removes the stopped container, but it does not remove the files written to the mounted host directory. You can replace the rasters in <code>data\/input\/<\/code> and run the existing image again without rebuilding it.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Override_the_target_CRS\"><\/span>Override the target CRS<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>The image declares default arguments, but the command line can override them:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>docker run --rm \\\r\n  --mount type=bind,source=\"$(pwd)\/data\",target=\/data \\\r\n  geo-pipeline:gdal-3.13.3 \\\r\n  --input-dir \/data\/input \\\r\n  --output-dir \/data\/output \\\r\n  --dst-crs EPSG:32737 \\\r\n  --overwrite<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The destination CRS is now UTM zone 37S, which may be suitable for some datasets in that region. Always choose a CRS appropriate to the actual study area and analytical purpose.<\/p>\n<p>On Windows PowerShell, a typical volume command uses <code>${PWD}<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>docker run --rm `\r\n  --mount \"type=bind,source=$($PWD.Path)\\data,target=\/data\" `\r\n  geo-pipeline:gdal-3.13.3 `\r\n  --input-dir \/data\/input `\r\n  --output-dir \/data\/output `\r\n  --dst-crs EPSG:4326<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Make sure Docker Desktop has permission to access the chosen host directory. On Linux, file ownership can also matter: a container process running as root may create output files that your regular user cannot easily modify. Use an appropriate non-root runtime user or pass a suitable user ID where the image and deployment environment support it.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"6_Step_4_%E2%80%94_Run_PyQGIS_in_a_Docker_Container\"><\/span>6. Step 4 \u2014 Run PyQGIS in a Docker Container<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-large wp-image-1599\" src=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_lzie2klzie2klzie-1024x559.jpg\" alt=\"Running headless PyQGIS processing workflows inside a Docker containe\" width=\"1024\" height=\"559\" srcset=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_lzie2klzie2klzie-1024x559.jpg 1024w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_lzie2klzie2klzie-300x164.jpg 300w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_lzie2klzie2klzie-768x419.jpg 768w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_lzie2klzie2klzie.jpg 1408w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/p>\n<p>Not every workflow should use the generic GDAL base image.<\/p>\n<p>If your code invokes QGIS Processing algorithms, loads QGIS plugins, or relies on QGIS-specific classes such as <code>QgsVectorLayer<\/code>, use a QGIS image. QGIS has its own Python bindings and application dependencies, so layering PyQGIS into an arbitrary Python image can create another dependency conflict.<\/p>\n<p>The official QGIS Docker project publishes images for QGIS Desktop and QGIS Server. At the time of writing, the generic <code>stable<\/code> and <code>latest<\/code> tags still point to QGIS 3.x on Ubuntu Noble, while distribution-specific tags are available for QGIS 4.0. Pin the QGIS release and base distribution appropriate to your application instead of treating <code>stable<\/code> as an immutable version. \u3010turn370650view0\u3011<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Minimal_QGIS_Dockerfile\"><\/span>Minimal QGIS Dockerfile<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Create a separate Dockerfile named <code>Dockerfile.qgis<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code># For a quick test, use the official QGIS image.\r\n# For production, pin a validated release\/distro tag.\r\nFROM qgis\/qgis:stable\r\n\r\nENV QT_QPA_PLATFORM=offscreen\r\nENV PYTHONUNBUFFERED=1\r\n\r\nWORKDIR \/opt\/qgis-app\r\n\r\nCOPY scripts\/ \/opt\/qgis-app\/scripts\/\r\n\r\n# QGIS prefix and Python paths are image-specific.\r\nENV QGIS_PREFIX_PATH=\/usr\r\nENV PYTHONPATH=\/usr\/share\/qgis\/python:\/usr\/share\/qgis\/python\/plugins\r\n\r\nENTRYPOINT [\"python3\", \"\/opt\/qgis-app\/scripts\/process_layer.py\"]<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The <code>QT_QPA_PLATFORM=offscreen<\/code> setting allows Qt-based code to operate without opening a desktop window. Confirm the QGIS prefix and Python paths against the selected image.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Example_headless_PyQGIS_script\"><\/span>Example headless PyQGIS script<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Create <code>scripts\/process_layer.py<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>\"\"\"Reproject a vector layer using the QGIS Processing framework.\"\"\"import osimport sysimport loggingfrom pathlib import Path# Set the headless Qt platform before importing QGIS.os.environ.setdefault(\"QT_QPA_PLATFORM\", \"offscreen\")from qgis.core import (    QgsApplication,    QgsCoordinateReferenceSystem,)logging.basicConfig(level=logging.INFO)logger = logging.getLogger(\"pyqgis-pipeline\")def main() -&gt; int:    input_layer = \"\/data\/input.gpkg|layername=parcels\"    output_file = \"\/data\/output\/reprojected.gpkg\"    if not Path(\"\/data\/input.gpkg\").exists():        logger.error(\"Input GeoPackage does not exist.\")        return 2    Path(output_file).parent.mkdir(        parents=True,        exist_ok=True,    )    # Initialize QGIS without a graphical interface.    QgsApplication.setPrefixPath(        os.environ.get(\"QGIS_PREFIX_PATH\", \"\/usr\"),        True,    )    app = QgsApplication([], False)    app.initQgis()    try:        # Initialize the QGIS Processing framework.        from processing.core.Processing import Processing        Processing.initialize()        import processing        result = processing.run(            \"native:reprojectlayer\",            {                \"INPUT\": input_layer,                \"TARGET_CRS\": QgsCoordinateReferenceSystem(                    \"EPSG:4326\"                ),                \"OUTPUT\": output_file,            },        )        logger.info(\"Processing completed: %s\", result[\"OUTPUT\"])        return 0    except Exception:        logger.exception(\"QGIS Processing failed.\")        return 1    finally:        app.exitQgis()if __name__ == \"__main__\":    raise SystemExit(main())<\/code><\/pre>\n<\/div>\n<div><\/div>\n<\/div>\n<p>This example assumes the input GeoPackage contains a layer named <code>parcels<\/code>, and that the output path is writable. Ensure the QGIS Processing plugin and native algorithms are available in the chosen image.<\/p>\n<p>Build the separate QGIS image:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>docker build \\\r\n  -f Dockerfile.qgis \\\r\n  -t geo-pipeline:qgis \\\r\n  .<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Run it with a host directory mounted:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>docker run --rm \\\r\n  --mount type=bind,source=\"$(pwd)\/data\",target=\/data \\\r\n  geo-pipeline:qgis<\/code><\/pre>\n<\/div>\n<\/div>\n<p>In practice, maintain separate GDAL and QGIS images when they serve different jobs. A basic raster reprojection script does not need the extra weight and complexity of a full QGIS installation.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"7_Step_5_%E2%80%94_Orchestrate_Containers_for_Automated_Batch_Jobs\"><\/span>7. Step 5 \u2014 Orchestrate Containers for Automated Batch Jobs<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Once the image works locally, the next step is to make the pipeline repeatable.<\/p>\n<p>Docker Compose provides a convenient way to store runtime configuration alongside the application. Create <code>compose.yaml<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>services:\r\n  raster-reproject:\r\n    build:\r\n      context: .\r\n      dockerfile: Dockerfile\r\n\r\n    image: geo-pipeline:gdal-3.13.3\r\n\r\n    volumes:\r\n      - .\/data:\/data\r\n\r\n    command:\r\n      - --input-dir\r\n      - \/data\/input\r\n      - --output-dir\r\n      - \/data\/output\r\n      - --dst-crs\r\n      - ${DST_CRS:-EPSG:4326}\r\n\r\n    restart: \"no\"<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Run the batch process with the default target CRS:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>docker compose run --rm raster-reproject<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Override it without editing the file:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>DST_CRS=EPSG:32737 \\\r\ndocker compose run --rm raster-reproject<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The same approach can be called by a scheduled job, a CI\/CD runner, or a larger workflow orchestrator.<\/p>\n<p>For processing separate batches independently, a shell loop can launch one container per batch:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>#!\/usr\/bin\/env bash\r\nset -Eeuo pipefail\r\n\r\nIMAGE=\"geo-pipeline:gdal-3.13.3\"\r\nDATA_ROOT=\"$(pwd)\/data\"\r\n\r\nfor batch_dir in \"$DATA_ROOT\"\/batches\/*; do\r\n    # Skip unmatched glob patterns and non-directory entries.\r\n    [[ -d \"$batch_dir\" ]] || continue\r\n\r\n    batch_name=\"$(basename \"$batch_dir\")\"\r\n    output_dir=\"$DATA_ROOT\/results\/$batch_name\"\r\n\r\n    mkdir -p \"$output_dir\"\r\n\r\n    echo \"Processing batch: $batch_name\"\r\n\r\n    docker run --rm \\\r\n        --mount \\\r\n        \"type=bind,source=$batch_dir,target=\/data\/input,readonly\" \\\r\n        --mount \\\r\n        \"type=bind,source=$output_dir,target=\/data\/output\" \\\r\n        \"$IMAGE\" \\\r\n        --input-dir \/data\/input \\\r\n        --output-dir \/data\/output \\\r\n        --dst-crs EPSG:4326\r\ndone<\/code><\/pre>\n<\/div>\n<\/div>\n<p>This pattern isolates each batch&#8217;s input and output, which makes reruns and error investigation easier. The script will stop on the first unsuccessful container because <code>set -e<\/code> is enabled; modify that behavior if you want to process every batch and produce a consolidated failure report instead.<\/p>\n<p>For a large production workload, also consider the number of simultaneous jobs, available RAM, raster dimensions, disk capacity, and the effect of multithreaded GDAL processing. More containers do not automatically mean faster processing.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"8_Lightweight_Geospatial_Containers_Best_Practices\"><\/span>8. Lightweight Geospatial Containers: Best Practices<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<h3><span class=\"ez-toc-section\" id=\"Keep_the_runtime_minimal\"><\/span>Keep the runtime minimal<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Install only the libraries your code needs. If the task is a simple GDAL raster transformation, start with a GDAL-focused image rather than installing the entire QGIS desktop stack. A smaller image generally reduces download time and the number of packages that require maintenance. Docker&#8217;s official build guidance discusses choosing appropriate base images, using multi-stage builds where beneficial, and minimizing unnecessary packages. \u3010turn814146view2\u3011<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Pin_and_test_the_complete_spatial_stack\"><\/span>Pin and test the complete spatial stack<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Record the base image version, Python package versions, and the tested source and target CRS behavior. For high-reproducibility workloads, record the container digest and preserve the lockfile or complete environment specification. Test upgrades before promoting the image to production.<\/p>\n<p>Be particularly cautious with the GDAL Python bindings and NumPy ABI compatibility. These are compiled dependencies, not just independent Python modules. \u3010turn236414search5\u3011<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Keep_data_and_code_separate\"><\/span>Keep data and code separate<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Use mounted volumes for large inputs and outputs. Keep credentials outside the image, and avoid committing local spatial data to the Docker build context. This separation makes the image reusable for different datasets and environments.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Validate_inputs_and_outputs\"><\/span>Validate inputs and outputs<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Do not measure success solely by the process exit code. For critical workflows, validate that the expected files exist, output datasets can be opened, CRS metadata is correct, feature or raster dimensions are reasonable, and processing statistics match expected ranges.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Treat_logging_as_an_operational_requirement\"><\/span>Treat logging as an operational requirement<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Write structured or timestamped logs to standard output and standard error. This allows Docker and your orchestration platform to collect execution records without writing logs into the image filesystem.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Automate_builds_and_tests\"><\/span>Automate builds and tests<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>A geospatial DevOps workflow can build the image whenever application code changes, run tests against a small sample dataset, verify GDAL and QGIS availability, and publish a tagged image only when the tests pass. Docker recommends automating image builds and tests in CI\/CD pipelines. \u3010turn370650view2\u3011<\/p>\n<h2><span class=\"ez-toc-section\" id=\"9_Summary\"><\/span>9. Summary<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Containerizing geospatial pipelines makes complex spatial runtimes easier to deploy and maintain. Docker packages the application code alongside a controlled runtime, reducing the environment drift that often occurs when GDAL, PROJ, GEOS, Python, and QGIS dependencies are installed separately on multiple machines.<\/p>\n<p>The workflow developed here demonstrates how to write a headless GDAL reprojection script, build a version-specific image, mount local data directories, process batches without rebuilding the image, and orchestrate recurring runs through Docker Compose or shell automation.<\/p>\n<p>Use a GDAL-focused image for straightforward raster and <a href=\"https:\/\/helpwithmygisassignment.com\/blog\/building-real-time-spatial-dashboards-in-qgis-with-geonode-and-postgis\/\">vector<\/a> operations, and a compatible QGIS image when your processing depends on PyQGIS or native QGIS algorithms. Keep native spatial dependencies compatible, pin versions, validate outputs, separate data from code, and automate tests before deployment.<\/p>\n<p>The goal of a geospatial DevOps workflow is not merely to make a script run inside Docker. It is to make the processing environment repeatable, testable, maintainable, and reliable across machines and deployments.<\/p>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Containerizing Geospatial Pipelines with Docker: Bundling GDAL, QGIS Scripts, and Python 1. Introduction: Containerizing Geospatial Pipelines Docker When Containerizing Geospatial Pipelines Docker architectures efficiently, developers often struggle with native dependency management. Geospatial Python applications depend on more than Python packages. A seemingly simple script that clips a raster, reprojects vector data, or processes satellite imagery &#8230; <a title=\"Containerizing Geospatial Pipelines with Docker: Bundling GDAL, QGIS Scripts, and Python\" class=\"read-more\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/containerizing-geospatial-pipelines-docker\/\" aria-label=\"Read more about Containerizing Geospatial Pipelines with Docker: Bundling GDAL, QGIS Scripts, and Python\">Read more<\/a><\/p>\n","protected":false},"author":1,"featured_media":1597,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[4],"tags":[],"class_list":["post-1442","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-gis-articles"],"_links":{"self":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts\/1442","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/comments?post=1442"}],"version-history":[{"count":3,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts\/1442\/revisions"}],"predecessor-version":[{"id":1600,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts\/1442\/revisions\/1600"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/media\/1597"}],"wp:attachment":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/media?parent=1442"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/categories?post=1442"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/tags?post=1442"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}