At my company, we sometimes build QGIS plugins. You can install those by hand by unzipping a zipfile in the correct directory, but there's a nicer way.
You can add custom plugin "registries" to QGIS and QGIS will then treat your plugins just like regular ones. Here's an example registry: https://plugins.lizard.net/ . Simple, right? Just a directory on our webserver. The URL you have to configure inside QGIS as registry is that of the plugins.xml file: https://plugins.lizard.net/plugins.xml .
The plugins.xml has a specific format:
<?xml version="1.0"?> <plugins> <pyqgis_plugin name="GGMN lizard integration" version="1.6"> <description>Download GGMN data from lizard, interpolate and add new points</description> <homepage>https://github.com/nens/ggmn-qgis</homepage> <qgis_minimum_version>2.8</qgis_minimum_version> <file_name>LizardDownloader.1.6.zip</file_name> <author_name>Reinout van Rees, Nelen & Schuurmans</author_name> <download_url>https://plugins.lizard.net/LizardDownloader.1.6.zip</download_url> </pyqgis_plugin> .... more plugins ... </plugins>
As you see, the format is reasonably simple. There's one directory on the webserver that I "scp" the zipfiles with the plugins to. I then run this script on the directory. That script extracts the (mandatory) metadata.txt from all zipfiles and creates a plugins.xml file out of it.
A gotcha regarding the zipfiles: they should contain the version number, but, in contrast to python packages, the version should be prefixed by a dot instead of a dash. So no myplugin-1.0.zip but myplugin.1.0.zip. It took me a while before I figured that one out!
About the metadata.txt: QGIS has a "plugin builder" plugin that generates a basic plugin structure for you. This structure includes a metadata.txt, so that's easy.
(In case you want to use zest.releaser to release your plugin, you can extend zest.releaser to understand the metadata.txt format by adding https://github.com/nens/qgispluginreleaser . It also generates a correctly-named zipfile.)