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.
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
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.
WMSServiceCapabilitiesis not true. - A layer you hid is still served.
WMSRestrictedLayerswas 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
WMSExtentwas 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.