Host a Private QGIS Plugin Repository
Not every plugin belongs on the public QGIS repository. Plugins that talk to internal systems, encode an organisation's workflows or contain licensed logic need to reach colleagues without reaching the world. Emailing ZIP files works once, but nobody gets updates. A private plugin repository gives internal plugins the same experience as public ones: they appear in the Plugin Manager, install with a click, and QGIS offers upgrades when a new version is published. The repository is nothing more than an XML file listing the plugins and their ZIPs, served from any web server or even a shared folder.
This recipe belongs to Publishing to the QGIS Plugin Repository. It explains the plugins.xml format, generates it from a folder of plugin ZIPs, hosts it, adds it to QGIS for every user, and protects it with authentication.
Prerequisites
- One or more plugins packaged as ZIPs with a valid
metadata.txt, as in packaging a plugin as a ZIP. - Somewhere to host static files: an internal web server, object storage, or a network share.
Understand plugins.xml
The index is a <plugins> element containing one <pyqgis_plugin> per plugin version. Its fields mirror metadata.txt.
<?xml version="1.0" encoding="UTF-8"?>
<plugins>
<pyqgis_plugin name="Asset Sync" version="1.4.0" plugin_id="asset_sync">
<description><![CDATA[Synchronise asset layers with the asset register]]></description>
<about><![CDATA[Internal plugin for the field operations team.]]></about>
<version>1.4.0</version>
<qgis_minimum_version>3.34</qgis_minimum_version>
<qgis_maximum_version>4.99</qgis_maximum_version>
<homepage>https://git.example.org/gis/asset_sync</homepage>
<file_name>asset_sync.1.4.0.zip</file_name>
<author_name>GIS Team</author_name>
<download_url>https://plugins.example.org/qgis/asset_sync.1.4.0.zip</download_url>
<experimental>False</experimental>
<deprecated>False</deprecated>
</pyqgis_plugin>
</plugins>
Breakdown: The name and version attributes identify the entry; QGIS compares version with the installed plugin to offer upgrades. qgis_minimum_version and qgis_maximum_version hide the plugin from QGIS versions it does not support. download_url is where the ZIP is fetched from — absolute, or a file:// path for a shared folder. experimental entries only appear for users who enable experimental plugins, which is a convenient way to offer beta versions to testers.
Generate the index from the ZIPs
Writing the XML by hand invites mistakes. A short script reads metadata.txt from each ZIP and writes the index, so publishing a version is just copying a ZIP and re-running it.
import configparser, pathlib, zipfile
from xml.sax.saxutils import escape
BASE_URL = "https://plugins.example.org/qgis/"
folder = pathlib.Path("/srv/qgis-plugins")
def read_metadata(zpath):
with zipfile.ZipFile(zpath) as z:
name = next(n for n in z.namelist() if n.endswith("/metadata.txt") and n.count("/") == 1)
cp = configparser.ConfigParser(interpolation=None)
cp.read_string(z.read(name).decode("utf-8"))
return name.split("/")[0], dict(cp["general"])
def vkey(v):
return tuple(int(p) if p.isdigit() else 0 for p in v.split("."))
latest = {}
for zpath in folder.glob("*.zip"):
pid, md = read_metadata(zpath)
if pid not in latest or vkey(md["version"]) > vkey(latest[pid][1]["version"]):
latest[pid] = (zpath, md)
entries = []
for pid, (zpath, md) in sorted(latest.items()):
entries.append(f""" <pyqgis_plugin name="{escape(md['name'])}" version="{md['version']}" plugin_id="{pid}">
<description><![CDATA[{md.get('description', '')}]]></description>
<about><![CDATA[{md.get('about', '')}]]></about>
<version>{md['version']}</version>
<qgis_minimum_version>{md.get('qgisminimumversion', '3.34')}</qgis_minimum_version>
<qgis_maximum_version>{md.get('qgismaximumversion', '3.99')}</qgis_maximum_version>
<homepage>{escape(md.get('homepage', ''))}</homepage>
<file_name>{zpath.name}</file_name>
<author_name>{escape(md.get('author', ''))}</author_name>
<download_url>{BASE_URL}{zpath.name}</download_url>
<experimental>{md.get('experimental', 'False')}</experimental>
<deprecated>{md.get('deprecated', 'False')}</deprecated>
</pyqgis_plugin>""")
(folder / "plugins.xml").write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n<plugins>\n' + "\n".join(entries) + "\n</plugins>\n",
encoding="utf-8")
print(f"indexed {len(entries)} plugins")
Breakdown: Each plugin ZIP contains a single top-level folder named after the plugin, with metadata.txt inside; the folder name is the plugin id. configparser reads the metadata keys, which it lower-cases. Only the newest version of each plugin is listed, so older ZIPs can stay in the folder for rollback without confusing users. Values that might contain markup are escaped or wrapped in CDATA. The same script can run as a step in an automated release workflow, after the ZIP is built.
Host the repository
Any server that serves static files works. For a file share, QGIS can read plugins.xml through a file:// URL as long as the download URLs are file paths too.
# Static web hosting — nginx example
# server { listen 443 ssl; server_name plugins.example.org;
# location /qgis/ { alias /srv/qgis-plugins/; autoindex off; } }
# Quick local test
cd /srv/qgis-plugins && python3 -m http.server 8000
# repository URL: http://localhost:8000/plugins.xml
Breakdown: Serve the folder over HTTPS on an internal host. Object storage buckets with static hosting work equally well, and their access policies can restrict who reads them. For a network share, set BASE_URL to something like file:///S:/qgis-plugins/ so both the index and the ZIPs resolve locally; this suits organisations without an internal web server, though HTTPS is easier for remote staff.
Add the repository to QGIS for every user
Each user can add the URL under Plugins ▸ Manage and Install Plugins ▸ Settings. To roll it out to everyone, write the repository into each profile's settings or set it in a startup script.
# startup.py in the QGIS profile folder, or run by IT once per profile
from qgis.core import QgsSettings
s = QgsSettings()
key = "app/plugin_repositories/Company plugins"
if not s.contains(key + "/url"):
s.setValue(key + "/url", "https://plugins.example.org/qgis/plugins.xml")
s.setValue(key + "/enabled", True)
s.setValue(key + "/authcfg", "")
Breakdown: Plugin repositories are stored under app/plugin_repositories/<name> in the user settings. Writing the URL there before the Plugin Manager opens adds the repository exactly as if the user had typed it. Placing the code in startup.py makes it apply to every launch; on managed machines a global settings file referenced by an environment variable does the same without per-user files. Settings keys can change between QGIS versions, so confirm them by adding a repository manually and inspecting the profile's QGIS3.ini.
Protect the repository
Internal plugins may encode details that should stay internal. Put the repository behind authentication and store the credentials in QGIS's authentication database.
from qgis.core import QgsApplication, QgsAuthMethodConfig, QgsSettings
cfg = QgsAuthMethodConfig("Basic")
cfg.setName("Plugin repository")
cfg.setConfig("username", "qgis-reader")
cfg.setConfig("password", "read-only-token")
QgsApplication.authManager().storeAuthenticationConfig(cfg)
QgsSettings().setValue("app/plugin_repositories/Company plugins/authcfg", cfg.id())
Breakdown: The web server requires a login for the repository path. The credentials live in the encrypted authentication database rather than in plain settings, and the repository entry references them by configuration id, so QGIS sends them when fetching both the index and the ZIPs. Use a read-only account that can do nothing but download plugins. For the wider pattern, see storing credentials with QgsAuthManager.
Publish, test and roll back releases
A repository is only as trustworthy as its release routine. A simple, repeatable sequence keeps users from receiving a broken version and makes recovery quick when one slips through.
# publish 1.4.1
cp dist/asset_sync.1.4.1.zip /srv/qgis-plugins/
python3 /srv/tools/build_index.py # regenerate plugins.xml
curl -fsS https://plugins.example.org/qgis/plugins.xml | grep 'asset_sync'
# roll back: remove the bad ZIP and regenerate
rm /srv/qgis-plugins/asset_sync.1.4.1.zip
python3 /srv/tools/build_index.py
Breakdown: Publishing is two steps — copy the ZIP, regenerate the index — followed by a quick check that the live index lists the new version. Because the generator always picks the newest ZIP per plugin, rolling back is just removing the faulty ZIP and regenerating; the previous version becomes the newest again. Users who already upgraded keep the faulty version until a higher version number appears, so the usual fix is to publish a corrected 1.4.2 rather than relying on rollback alone.
Before a version goes to everyone, publish it with experimental=True in its metadata. Testers who enable experimental plugins receive it through the same repository and can report problems; everyone else stays on the stable version. When the release has proved itself, rebuild the ZIP with experimental=False and the same version number. A second option for larger organisations is to run two repositories — a testing one added only on testers' machines and a stable one for everyone — fed by the same generator script with different folders.
Keep an eye on what users actually run. The web server's access log records which ZIPs were downloaded and when, which answers questions like whether a fix has reached the field team and which old versions are still in use before you drop support for them.
QGIS version compatibility
The plugins.xml format and private repositories work identically on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. Use qgis_maximum_version to keep plugins that are not yet ported to Qt 6 from being offered on QGIS 4.
Troubleshooting
- The repository shows an error in the Plugin Manager. The XML is malformed or unreachable; open the URL in a browser.
- A plugin is missing from the list. Its minimum or maximum QGIS version excludes the user's version, or it is marked experimental.
- Upgrades are not offered. The version in
plugins.xmlis not higher than the installed one. - Downloads fail but the index loads.
download_urlpoints to the wrong host or path.
Conclusion
A private repository is a plugins.xml index plus a folder of ZIPs. Generate the index from the ZIPs' metadata, host it on an internal web server or share, add the repository for every user with settings or a startup script, and protect it with authentication stored in QGIS.
Frequently Asked Questions
Can one repository hold many plugins?
Yes; list every plugin in the same plugins.xml.
Can I offer beta versions? Mark them experimental; only users who enable experimental plugins see them.
Does the repository need a database? No; static files are enough.
Can users still use the public repository? Yes; QGIS checks all enabled repositories together.