Publish Layers with QGIS Server from PyQGIS

QGIS Server publishes a QGIS project. Everything it serves — the layer list, the styling, the extents, the metadata in the capabilities document — comes from that project file, which means preparing a project for publication is a scripting job like any other. Doing it in Python rather than through the project properties dialog is what lets a hundred projects be generated and republished on a schedule.

This recipe belongs to Headless QGIS & Server Automation. It covers the project entries that drive the service metadata, restricting which layers are published, per-service layer lists, and verifying the result before it reaches a server.

The project file is the configurationQGIS Server reads a project file on each request. The project's server settings decide the service title and contact metadata, which layers appear, and which are published as web feature services. Clients then read the capabilities document and request rendered maps or raw features.No server-side config file — the project is the configproject.qgslayers · styles · extentsserver entriesQGIS Serverreads it per requestcaches parsed projectscapabilities documenttitle, contact, layer list, extentswhat every client reads firstthe three lists that matterWMSRestrictedLayers — hiddenWFSLayers — feature accessWFSTLayers — editableeach holds layer ids, not layer namesa layer left out of WFSLayers is still visible as an image, just not downloadable

Prerequisites

  • QGIS 3.34 LTR or newer for authoring; QGIS Server of a matching version for serving.
  • A project whose layers use paths the server can reach — absolute paths, or relative to the project.
  • Credentials for any database layers stored in a way the server can use, not in a personal profile.

Set the service metadata

from qgis.core import QgsProject

project = QgsProject.instance()
project.read("/data/projects/utilities.qgs")

project.writeEntry("WMSServiceTitle", "/", "City Utilities")
project.writeEntry("WMSServiceAbstract", "/",
                   "Water, drainage and lighting assets, updated nightly.")
project.writeEntry("WMSKeywordList", "/", ["utilities", "water", "lighting"])
project.writeEntry("WMSContactOrganization", "/", "City Council GIS")
project.writeEntry("WMSContactMail", "/", "gis@example.gov")
project.writeEntry("WMSOnlineResource", "/", "https://maps.example.gov/ows")

Breakdown: writeEntry is the generic project-settings API, and the server reads these exact keys — there is no dedicated server object to configure in older releases, which is why the string keys matter. The second argument is the settings path and is "/" for all of these. Values can be strings, booleans, numbers or string lists, and the type must match what the server expects: WMSKeywordList takes a list, and passing a comma-joined string produces one keyword containing commas. Everything written here appears verbatim in the capabilities document, which is the first thing any client and any catalogue harvester reads.

From QGIS 3.20 onwards project.serverProperties() exposes much of this through typed methods, which is more discoverable; the writeEntry form above works across every 3.x release, which is why a script that must span versions uses it.

Turn the services on and bound them

project.writeEntry("WMSServiceCapabilities", "/", True)
project.writeEntry("WMSPrecision", "/", "6")

extent = project.mapLayersByName("water_mains")[0].extent()
project.writeEntry("WMSExtent", "/", [
    str(extent.xMinimum()), str(extent.yMinimum()),
    str(extent.xMaximum()), str(extent.yMaximum()),
])

project.writeEntry("WMSCrsList", "/", ["EPSG:27700", "EPSG:3857", "EPSG:4326"])

Breakdown: WMSServiceCapabilities must be true or the metadata above is ignored and the server advertises defaults. WMSExtent is the advertised bounding box, and setting it from a representative layer rather than from the union of everything stops one stray feature at the antimeridian advertising a worldwide extent. WMSCrsList restricts which projections the server will reproject into; leaving it unset advertises a very long list that makes the capabilities document large and slow for every client. Web Mercator belongs in that list if the service will be consumed by web maps, as covered in adding an XYZ tile basemap.

Choose which layers are published

published = {"water_mains", "hydrants", "street_lighting"}

restricted = []
wfs_ids = []
for layer in project.mapLayers().values():
    if layer.name() not in published:
        restricted.append(layer.name())
    elif layer.type().name == "VectorLayer":
        wfs_ids.append(layer.id())

project.writeEntry("WMSRestrictedLayers", "/", restricted)
project.writeEntry("WFSLayers", "/", wfs_ids)
project.writeEntry("WFSTLayers", "/Update", [])
project.writeEntry("WFSTLayers", "/Insert", [])
project.writeEntry("WFSTLayers", "/Delete", [])

Breakdown: The asymmetry here catches people out: WMSRestrictedLayers holds layer names while WFSLayers holds layer ids. Mixing them produces a project that silently publishes what you meant to hide. Restricting a layer removes it from the capabilities and refuses requests for it — it is a real access control, not just a display hint. The three WFSTLayers entries control transactional write access and should be explicitly empty unless you genuinely intend to let clients edit the data over HTTP; an unset entry is not the same as an empty one on every release, and being explicit costs nothing.

Per-layer metadata that shows up in the service

Layer metadata is the service's documentationEach layer's short name becomes the identifier clients request. Its title and abstract become the human-readable description in the capabilities document. Attribution appears in clients that display it. All of them come from the layer's metadata in the project rather than from the underlying data.Nobody reads your project; everybody reads your capabilitiesset in Pythonappears aslayer.setShortName()the LAYERS= identifier clients sendlayer.setTitle()the human-readable layer namelayer.setAbstract()the description in the capabilitieslayer.setAttribution()credit shown by capable clientsa short name with a space in it is rejected by strict clientsset short names once — changing them later breaks every saved client bookmark

for name in published:
    layer = project.mapLayersByName(name)[0]
    layer.setShortName(name.replace(" ", "_").lower())
    layer.setTitle(name.replace("_", " ").title())
    layer.setAbstract(f"{layer.featureCount()} features, updated nightly.")
    layer.setAttribution("© City Council")

Breakdown: The short name is the identifier a client puts in its LAYERS= parameter, so it must be stable and should avoid spaces and non-ASCII characters — some clients quote them correctly and some do not. Setting it explicitly rather than letting the layer name serve is what protects existing clients when someone renames a layer for cartographic reasons. Deriving the abstract from the data, as here, keeps it honest across republication; the more general metadata model is described in reading and writing layer metadata.

Save and verify before deploying

project.write("/data/projects/utilities.qgs")

reopened = QgsProject()
reopened.read("/data/projects/utilities.qgs")

title, ok = reopened.readEntry("WMSServiceTitle", "/")
restricted, ok = reopened.readListEntry("WMSRestrictedLayers", "/")
print("title:", title)
print("hidden layers:", restricted)
print("published:", [l.name() for l in reopened.mapLayers().values()
                     if l.name() not in restricted])

Breakdown: Reading the project back into a fresh QgsProject instance — rather than trusting the in-memory one you just configured — is what catches an entry that was written with the wrong type or under the wrong key. readListEntry is the counterpart to writing a list and returns a (value, ok) tuple like most of these readers; ignoring the ok flag turns a missing key into an empty list that looks like a deliberate empty restriction. Printing the effective published set is the check worth having in any deployment script, because "which layers does this actually expose" is the question that matters and the one no dialog answers directly.

Generating a service project from a template

The reason to do any of this in Python rather than in the dialog is that it scales. A template project carrying the styling and the service metadata, plus a script that swaps in the data sources, produces one service per dataset with no manual work.

import os

TEMPLATE = "/data/templates/service_template.qgs"

def build_service(area_name, gpkg_path, out_dir):
    project = QgsProject()
    project.read(TEMPLATE)

    for layer in project.mapLayers().values():
        source = layer.source()
        if "TEMPLATE.gpkg" in source:
            layer.setDataSource(
                source.replace("TEMPLATE.gpkg", gpkg_path),
                layer.name(), layer.providerType(),
            )

    project.writeEntry("WMSServiceTitle", "/", f"{area_name} Utilities")
    project.writeEntry("WMSOnlineResource", "/",
                       f"https://maps.example.gov/ows/{area_name.lower()}")

    target = os.path.join(out_dir, f"{area_name.lower()}.qgs")
    project.write(target)
    return target

Breakdown: setDataSource re-points a layer at different data while keeping its styling, its short name and everything else the service depends on — which is the whole trick, because re-creating the layer would lose all of it. Passing the existing providerType() back in rather than hard-coding "ogr" keeps the function usable for PostGIS layers too. Working on a fresh QgsProject() rather than QgsProject.instance() matters here: the singleton is shared, so a loop over twenty areas using it would accumulate state between iterations. Each call writes an independent project, and the server picks whichever the MAP parameter names.

Running this from a nightly job, followed by the read-back verification above, means a published service that is never out of step with the data behind it.

QGIS version compatibility

The writeEntry keys used here have been stable across QGIS 3 and are shared with QGIS Server of the same major version. QgsProject.serverProperties() arrived in 3.20 and offers typed accessors for the same settings. Serving a project authored in a newer QGIS from an older server generally works for simple styling and fails on newer symbol types, so keep the authoring and serving versions aligned.

Troubleshooting

  • The capabilities document shows default metadata. WMSServiceCapabilities is not true.
  • A layer you hid is still served. WMSRestrictedLayers was given ids instead of names.
  • WFS returns nothing for a published layer. It is missing from WFSLayers, which takes ids.
  • Clients cannot find a layer they used yesterday. A short name changed, or was never set and the layer was renamed.
  • The advertised extent covers the world. One feature has a bad geometry, or WMSExtent was not set.
  • The server cannot open the project's layers. Relative paths that resolve differently, or database credentials stored in a personal authentication database.

Conclusion

Set the service metadata explicitly, bound the advertised extent and CRS list, be precise about which list takes names and which takes ids, give every published layer a stable short name, and read the project back to verify before deploying. A published service is a contract with its clients, and all of it lives in a project file you can generate.

Frequently Asked Questions

Can I test the capabilities without a web server? Yes — the qgis_mapserv.fcgi binary can be run from the command line with the request parameters in the environment, which is enough to render a capabilities document and check it.

How do I serve several projects? One project per service endpoint, selected by the MAP parameter. Generating those projects from a template in Python is the natural extension of this recipe.

Does the server honour scale-based visibility? Yes, along with most other rendering settings, which is why the project is the configuration — see setting scale-based visibility.

Where do database credentials go? Into the data source URI in a form the server user can read, or into a service file. A personal authentication database is not readable by the server process.