Breadcrumb
-
- Blog
- Publishing Custom Drupal Modules and Recipes via GitLab's Composer Registry
Publishing Custom Drupal Modules and Recipes via GitLab's Composer Registry
When you're building custom Drupal modules or recipes for a client — or for your team's reusable internal toolkit — sticking them on Drupal.org isn't always the right answer. They might be proprietary, half-baked, or just specific enough that contributing them publicly doesn't make sense. The cleanest way to manage them is to host the code on your own GitLab and let Composer pull them down exactly the way it pulls down anything else from packagist.org.
Here's the setup, end-to-end.
The two files you need
Every module or recipe repo needs three things: the actual functional code, a composer.json, and a .gitlab-ci.yml. The first describes the package, the second publishes it to GitLab's package registry every time you push a tag.
composer.json
At an absolute minimum:
{
"name": "my-package/hello_world",
"description": "A simple Hello World module",
"type": "drupal-module",
"license": "GPL-2.0-or-later"
}The type matters — drupal-module for modules, drupal-recipe for recipes, drupal-theme for themes. The composer/installers plugin uses this to decide where to drop the package on disk inside a Drupal site.
In real-world use you'll want a require block too. Most Drupal modules need at least drupal/core (or drupal/core-recommended) declared as a dependency so Composer can resolve compatibility correctly:
"require": {
"drupal/core": "^10 || ^11"
}Skipping this works on day one but bites you later when somebody installs your module against an incompatible core version.
What about a version field?
You might be wondering why the example doesn't include a "version": "1.0.1" line. That's deliberate. Composer's official guidance is to leave it out when your package is published through a VCS or a registry that derives versions from git tags — which is exactly what we're doing here. GitLab reads the tag you push (1.0.1) and registers it as the package's version automatically.
Hardcoding a version in composer.json doesn't help, and can actively hurt:
- It can fall out of sync with the tag. If
composer.jsonsays"version": "1.0.0"but you push tag1.0.1, you now have two competing versions floating around. Different tools resolve this differently and you end up debugging weird "why isn't it picking up my fix?" issues. - It's another thing to remember to bump. Tag the release and you're done — no second source of truth to forget about.
- It interferes with Composer's branch-aliasing. Composer uses the absence of a static version as a signal to derive versions from git context (tag, branch,
-devsuffixes, etc.). Hardcoding a version overrides all of that.
The only time you'd want a version field is when there's no tag-aware source for Composer to look at — for example, a package shipped as a tarball with no VCS metadata at all. That's not the case here.
.gitlab-ci.yml
This pipeline runs on every push and tells GitLab to register the package in its Composer registry:
image: alpine:3.20
stages: [deploy]
deploy:
stage: deploy
script:
- apk add --no-cache curl
- |
if [ -n "$CI_COMMIT_TAG" ]; then
data="tag=$CI_COMMIT_TAG"
else
data="branch=$CI_COMMIT_REF_NAME"
fi
echo "Publishing Composer package with: $data"
curl --fail-with-body --show-error --silent \
--header "Job-Token: $CI_JOB_TOKEN" \
--data "$data" \
"${CI_API_V4_URL}/projects/$CI_PROJECT_ID/packages/composer"
environment: productionThe script does one thing: it hits GitLab's Composer API and says, "register this branch or tag as a package version." CI_JOB_TOKEN is injected automatically by the runner, so you don't need to set up auth tokens or deploy keys.
Tag, push, publish
Commit and push as you normally would. Get the MR reviewed, merge it. Then it's tag time.
To see the latest tag already in the repo:
git describe --tags --abbrev=0
# or
git tag --sort=-v:refname | head -n 1Bump it semantically (major.minor.patch) and push:
git tag -a 1.0.1 -m "Fix typo in install hook"
git push origin 1.0.1A note on -a: without -m, Git drops you into your editor to write the annotation message. Not a problem, just mildly surprising the first time. If you'd rather skip the annotation entirely, drop the -a flag and use git tag 1.0.1 for a lightweight tag.
As soon as the tag lands on GitLab, the pipeline kicks off, and you'll get an email when it finishes. Browse to Deploy → Package registry in the project to confirm the new version is listed.
Why groups, and how to find the group ID
Here's a detail that catches people off guard: GitLab exposes Composer registries at the group level. There's no equivalent endpoint for personal namespaces, so all your published packages need to live inside groups (or subgroups) for the consumer side to work cleanly.
Setting that up is straightforward:
- In GitLab, go to Groups → New group — or New subgroup if you want to nest it under an existing parent group, which is handy if you want to separate, say, modules from recipes.
- Give it a name and pick a visibility level. Private is fine; the registry endpoint will still work as long as the consuming side authenticates.
- Create your module or recipe projects inside that group. There's nothing special about their structure — they're regular GitLab projects.
To point Composer at the registry, you'll need the group's numeric ID. The easiest way to find it: open the group's main page in GitLab, and the ID is displayed right under the group name, with a copy-to-clipboard icon next to it. If for some reason you can't see it in the UI, hit the API instead — GET https://gitlab.com/api/v4/groups/your-group-path returns the group's metadata including its id.
Once you have the ID, register the group as a Composer repository inside the consuming Drupal site's composer.json:
{
"type": "composer",
"url": "https://gitlab.com/api/v4/group/GROUP_ID_NUMBER/-/packages/composer/packages.json"
}Replace GROUP_ID_NUMBER with the ID of the group you want to pull from. If your site needs to pull from multiple groups — for example, one group for modules and another for recipes — add an entry for each. Composer checks each registry in turn. After that, composer require your-namespace/your_thing Just Works.
Wrap-up
Once the boilerplate is in place, the day-to-day rhythm is the same as any other Composer dependency: write code, merge, tag, publish, require. The upfront cost of setting up the two files pays for itself the first time you avoid a custom git submodule, a manual download script, or a copy-pasted module folder that drifts out of sync across projects.
More insights
Get ready to transform your operations
Sign up for a free trial and discover a world of possibilities to elevate your outreach and engagement strategies