Private Composer Repositories in Magento 2: Distributing Custom Modules Cleanly
AI generated
M2
di.xml
Magento 2 · Composer · Satis · Module Distribution
Private Composer repositories
for distributing your own Magento modules

Any agency maintaining custom Magento 2 modules across several client projects cannot avoid a private Composer repository. Instead of distributing modules manually via FTP or a zip file, a private Composer repository makes them installable through composer require, versioned through git tags and with full dependency resolution against magento/framework. This article shows how to build a solid distribution infrastructure, from the repositories configuration through a self hosted Satis instance to a secured CI pipeline.

18 min read repositories key · Satis · auth.json · CI Magento 2.4.8 · Composer 2 · PHP 8.4

1. Why a private Composer repository is essential for agencies

Every agency that maintains custom Magento 2 modules across several client projects knows the problem: a module gets built for project A, and shortly after, the same functionality needs to land in project B or C. Without a structured distribution mechanism, this almost always ends in manually copying a zip file over FTP into the app/code folder, often in a version that no longer matches the current state of development. A private Composer repository solves exactly this distribution problem by making custom modules installable through composer require just like public packages from Packagist, complete with correct version numbers and automatic dependency resolution.

The real value of a private Composer repository only shows up once it interacts with Composer's dependency resolution. A module that internally requires magento/framework at a specific version, or that depends on a second custom package, can be installed reproducibly across every environment through composer.lock, from the local development machine through the CI pipeline to the production server. That exact reproducibility disappears completely the moment modules are copied manually: Composer no longer knows which version is installed, and a composer update can, in the worst case, strip out manually applied code again or silently ignore it.

A Composer repository in its private form also lays the groundwork for clean release management. Every new module version gets its own version number, referenced through a git tag, so each client project can stay precisely on the version that was tested and approved while another project already updates to a newer minor version. Without this repository, that version discipline would have to be recreated by hand through folder names or comments in a README, which in practice drifts apart on a regular basis.

2. The repositories key in composer.json: vcs, composer and path

Composer looks for packages on packagist.org by default. For a custom, private module to be found at all, it has to be registered in the repositories key of a project's root composer.json, either per project or centrally through a shared base composer.json that every client project includes. Composer searches all configured repositories in the given order until it finds a package with a matching name and a version that satisfies the requested constraint.

The type "vcs" points directly at a git repository, for example a private GitLab or GitHub URL. Composer reads the tags and branches of that repository directly, with no additional package metadata needed. This is the simplest way into a private Composer repository, but it scales poorly: every single module needs its own vcs entry in the project, which quickly becomes unmanageable for an agency with a dozen or more custom modules.

The type "composer" instead points to a single endpoint behind which a static or dynamic package feed lives, for example a self hosted Satis instance, Private Packagist, a Nexus repository, or a comparable toolkit. A single entry in the repositories key is then enough, no matter how many private modules are actually managed behind it. The type "path" finally points at a local directory and is used primarily for local development: where the operating system supports it, Composer creates a symlink instead of a real copy inside vendor/, so changes to the module become visible immediately without running composer update again.


{
  "repositories": [
    {
      "type": "composer",
      "url": "https://satis.mironsoft.de"
    },
    {
      "type": "vcs",
      "url": "https://git.mironsoft.de/mironsoft/module-seosuite.git"
    },
    {
      "type": "path",
      "url": "../local-modules/module-seosuite",
      "options": {
        "symlink": true
      }
    }
  ]
}

3. Running your own Satis instance for the private Composer repository

Satis is Composer's own official tool for generating a single static package feed out of several git repositories, which makes it the obvious choice for a self hosted solution. Installation happens via composer create-project composer/satis, and the actual configuration lives in a satis.json listing every module repository to include, along with its git URL and optional version and name filters. The command satis build satis.json public/ reads this configuration, clones or updates every referenced repository, and writes a packages.json together with all release archives into the output directory.

Since Satis only produces static files, a freshly created git tag on a module repository stays invisible to Composer until Satis is rebuilt. In practice, this is handled by a cron job that reruns satis build on a fixed interval, for example every fifteen minutes, or by a webhook that triggers a rebuild right after a git push. The output directory public/ is then served over HTTPS through nginx or Apache, with directory listing deliberately disabled so nobody can browse the feed without a valid package URL.

The big advantage of this static approach over a dynamic solution is that answering Composer requests needs no PHP process and no database at runtime, a plain web server is entirely sufficient. The downside is the delay already mentioned between tagging and visible availability in the private Composer repository, which, with too large a cron interval, leads to confusing situations where a version just released is resolvable locally but not yet in the CI pipeline.


{
  "name": "mironsoft/private-satis",
  "homepage": "https://satis.mironsoft.de",
  "repositories": [
    { "type": "vcs", "url": "https://git.mironsoft.de/mironsoft/module-seosuite.git" },
    { "type": "vcs", "url": "https://git.mironsoft.de/mironsoft/module-blog.git" }
  ],
  "require": {
    "mironsoft/module-seosuite": "^1.0",
    "mironsoft/module-blog": "^2.0"
  },
  "require-all": false,
  "output-html": false,
  "archive": {
    "directory": "dist",
    "format": "zip"
  }
}

#!/usr/bin/env bash
# Cron entry rebuilding the static packages.json every 15 minutes
# */15 * * * * cd /var/www/satis && php bin/satis build satis.json public/ >> var/log/satis.log 2>&1

# Manual rebuild after a webhook fires on a new git push
php bin/satis build satis.json public/

# Resolve and install a module from the private Composer repository
bin/composer require mironsoft/module-seosuite:^1.2

# Update only a single package against the rebuilt feed
bin/composer update mironsoft/module-seosuite --with-dependencies

4. Securing access to the private Composer repository: auth.json and COMPOSER_AUTH

A private Composer repository is, by definition, not meant for public access, so both access to private git repositories of type vcs and access to a Satis or Private Packagist endpoint usually require authentication. Composer reads the necessary credentials from a file called auth.json, which can live either project locally next to composer.json or globally in the COMPOSER_HOME directory. Supported methods include http-basic for classic username and password combinations, as used by a simple Satis instance behind a reverse proxy with basic auth, as well as bearer tokens, as typically issued by Private Packagist or Nexus.

auth.json must never be checked into a git repository, it belongs consistently in every project's .gitignore. For environments where no file should be stored on disk at all, for example short lived CI containers or while building a Docker image, Composer offers the COMPOSER_AUTH environment variable. It accepts exactly the same JSON structure as an auth.json file, but gets injected as a string through the environment and therefore never ends up as a file in an image layer or in the repository.

In the Mark Shust Docker setup, COMPOSER_AUTH can conveniently be set as an environment variable of the PHP container through compose.yaml, or passed as a build argument to a multi stage Dockerfile build, without credentials ever being visible in the finished image. Alternatively, an auth.json can be mounted read only into the container without it ever becoming part of the repository. Either way, bin/composer install inside the container reliably reaches the private Composer repository without developers having to enter credentials manually into composer.json.


{
  "http-basic": {
    "satis.mironsoft.de": {
      "username": "ci-deploy",
      "password": "REPLACE_WITH_SECRET"
    }
  },
  "bearer": {
    "repo.packagist.com": "REPLACE_WITH_TOKEN"
  }
}

5. Preparing a Magento module's composer.json for distribution

For a custom Magento module to be installable through a private Composer repository at all, its own repository needs a valid composer.json. The type magento2-module is decisive here, since it tells Magento's Composer plugin how and where the package must be installed, namely via symlink or copy into app/code/Vendor/Module, instead of into vendor/ like an ordinary package. The name follows the convention vendor/module-name in lowercase with hyphens, but must correspond in substance to the PHP namespace Vendor\Module that Magento's own module system expects through registration.php and etc/module.xml.

The autoload block with psr-4 maps the module's namespace to its root directory, so Composer wires the module's classes correctly into the autoloader. Equally important is a properly set require entry for magento/framework with a suitable version constraint, so Composer automatically checks during installation whether the target environment is even compatible, instead of installing a module into an incompatible Magento version and only surfacing the error at runtime.

The boundary matters here: composer.json only governs distribution and installation of the package through the Composer repository, not the module's registration inside Magento's own module system. registration.php and etc/module.xml remain untouched by it and must be maintained correctly regardless. A common mistake is also the outdated extra.installer-name key from early Magento 2 versions, which is no longer needed since the dedicated magento2-module Composer plugin and can simply be dropped in new modules.


{
  "name": "mironsoft/module-seosuite",
  "description": "Mironsoft SeoSuite module for Magento 2",
  "type": "magento2-module",
  "license": "proprietary",
  "require": {
    "php": "~8.4.0",
    "magento/framework": "^103.0"
  },
  "autoload": {
    "psr-4": {
      "Mironsoft\\SeoSuite\\": ""
    },
    "files": [
      "registration.php"
    ]
  }
}

6. Versioning by git tag: how Composer resolves version constraints

Composer resolves version constraints such as ^1.2, ~1.4 or a fixed version exclusively against git tags that actually exist in the module repository. Every release of a new module version therefore needs its own tag in the format v1.2.0, optionally without the leading v, since Composer normalizes both notations internally. Without such a tag, neither a vcs entry nor a Satis instance sees any installable version at all, the module would only be usable through an unstable dev-main reference.

In practice the flow looks like this: the tag gets created and pushed locally once a feature is finished and tested, which, in a Satis based setup, triggers either the next cron rebuild or an immediate webhook rebuild. Only after that does the new version show up in the packages.json feed of the private Composer repository and can be requested by a consuming project through composer update vendor/module, provided the version constraint configured there allows it.

This is deliberately not the place to go into the details of correct SemVer numbering or a breaking change policy, a separate, more in depth article on Magento module versioning covers that. For the pure distribution mechanics of a private Composer repository, one thing matters above all: consistent tagging discipline is the precondition for version constraints to mean anything at all and for Composer to deliver reproducible installs.

7. Running CI pipelines with access to the private Composer repository

A composer install runs multiple times in practically every CI pipeline, across the test, build and deploy stages. For this step to work against a private Composer repository at all, the pipeline runner needs the same credentials a developer would use locally, usually stored as COMPOSER_AUTH in the protected CI variables of GitLab CI or GitHub Actions, never in plain text in the repository. On top of that, the runner itself needs network access to the Satis host or git server, which, under restrictive firewall or VPN rules, means the CI runners' IP ranges need to be explicitly allowed too.

For pipeline runtime, a Composer cache directory that persists between pipeline runs is also worth having, instead of re downloading every module archive from the private Composer repository on every single build. A cached directory can save several minutes of pipeline runtime, especially on larger projects with many custom modules. With loosely defined version constraints such as dev-main, though, this cache needs a deliberate invalidation strategy so an outdated commit state doesn't get shipped by accident.

A typical failure mode occurs when a Satis instance's static packages.json has not yet been rebuilt: a developer pushes a new tag, composer update already works locally, while the same pipeline shortly after still resolves the old version because the cron rebuild only runs in a few minutes. This exact scenario is a strong argument for triggering the rebuild of the Composer repository through a webhook rather than only a cron interval.

8. Repository types compared: vcs, composer, path and Packagist

Before setting up a private Composer repository concretely, it is worth comparing the available repository types directly, since they differ noticeably in setup effort, typical use case and versioning support.

Repository type Setup effort Use case Versioning support
vcs Low, just add a URL Single module, quick start, edge cases Directly through git tags and branches
composer (Satis / Private Packagist) Medium to high, own instance or SaaS Many custom modules across several client projects Central feed, versioned across all repositories
path Very low Active local development via symlink No real versioning, always the current state
Packagist (public) None, already available Public dependencies such as magento/framework Full SemVer support

In practice, an agency usually combines several of these types: path for active local development, composer through a Satis or Private Packagist instance for staging and production, and plain Packagist as usual for all public dependencies such as magento/framework itself. The type vcs remains useful for individual edge cases or a quick start, before investing in a full Satis instance is even worthwhile.

9. A practical workflow: from a local path repository to a production release

During the active development phase of a module, a developer typically adds a path repository to their local composer.json pointing at the local checkout of the module repository. That allows immediate testing of every code change in the target project, without the detour of commit, tag, push and another composer update against the private Composer repository.

Once a feature is finished and tested, it gets committed, a matching git tag is created and pushed. The local path repository is then removed or replaced by the regular composer repository configuration, so staging and production consistently obtain the tagged version through the private Composer repository, never through a local filesystem path that does not exist on a production server anyway.

This flow mirrors exactly the reality of an agency with several client projects: the same module version gets requested in parallel by several composer.json files across different projects, each with its own, deliberately chosen version constraint. The private Composer repository acts as the single, reliable source serving all projects simultaneously, without anyone ever having to manually copy a zip file anywhere.

Mironsoft

Composer infrastructure, Satis operation and CI integration for Magento 2 modules

Want your own Magento modules distributed reliably?

We set up your private Composer repository, from the Satis instance through auth.json security to a CI pipeline that reliably reaches your own modules on every build.

Satis setup

Setting up satis.json, a cron or webhook rebuild and an HTTPS feed cleanly

Secure distribution

Configuring auth.json and COMPOSER_AUTH without credentials in the repository

CI integration

Wiring Composer access and caching reliably into your pipeline

10. Summary

A private Composer repository is the infrastructure foundation for any agency maintaining custom Magento 2 modules across more than one client project. Whether through a simple vcs entry, a self hosted Satis instance, or a managed service such as Private Packagist, what matters is that Composer can resolve custom modules just as reliably as public packages from Packagist, complete with correct version resolution and dependency checks against magento/framework.

Operating a private Composer repository brings additional responsibility, from securing it through auth.json and COMPOSER_AUTH to making sure CI pipelines see the same access and the same freshness a developer would see locally. Whoever wires these building blocks up cleanly gains a distribution infrastructure that grows with the number of custom modules and client projects, instead of manually copying code back and forth for every new project.

Private Composer repository: the essentials at a glance

Why a private repository

Versioned, reproducible distribution of custom modules instead of manual FTP copies across several client projects.

Repository types

vcs for a quick start, composer through Satis or Private Packagist for production, path for local development.

Security

auth.json and COMPOSER_AUTH protect credentials, never in the repository, always as an environment variable or a read only mount.

CI operation

Same access as locally, cached Composer downloads and webhook rebuilds instead of long cron intervals.

11. FAQ: Private Composer repository for Magento 2

1What is a private Composer repository and why does an agency need one?
It makes custom, non public Magento modules installable through composer require instead of copying them manually via FTP or zip, including versioning and dependency resolution.
2What is the difference between type vcs and type composer?
vcs points directly at a single git repository, composer points at a central feed such as Satis or Private Packagist for any number of modules.
3How do I set up my own Satis instance?
Through composer create-project composer/satis, a satis.json with the module repositories, and satis build satis.json public/, served over HTTPS.
4How often does Satis need to be rebuilt?
Common practice is a cron interval of about 15 minutes or a webhook right after every git push.
5How does authentication through auth.json work?
Through http-basic or bearer tokens, project local or global in COMPOSER_HOME, never checked into a git repository.
6What does COMPOSER_AUTH do?
Injects the same JSON structure as auth.json as an environment variable, ideal for CI containers and Docker images.
7What does a module's composer.json need?
Type magento2-module, name vendor/module-name, psr-4 autoload and a require entry for magento/framework.
8Why does Composer need git tags for versions?
Composer resolves version constraints only against existing tags. Without a tag, a module is only usable through an unstable dev-main reference.
9How does a CI pipeline access the repository?
Through COMPOSER_AUTH in protected pipeline variables, plus network access from the runner to the Satis host or git server.
10Is type path suitable for production environments?
No, path points at a local path. Staging and production should install through composer or vcs repositories.