One Deployable, Many Repositories: Migrating a Multi-Site AEM Program to Cloud Service with Git Submodules

How we kept three independently-run product repositories — plus a shared library every team owns together — on one Cloud Manager pipeline, and why we ended up with three pipeline types instead of one.

Architecture retrospective, current as of Cloud Manager 2026.9. This describes an architecture we actually operated, not a timeless spec. Some of the Cloud Manager constraints below (§8 especially) were true when we built this and may have moved on since — check Adobe's current documentation before assuming a limitation still holds.

TL;DR

If you are staring down an on-prem → AEMaaCS migration with more than one codebase, this is the article I wish I'd had.

1. The starting point: three repos, three deployments, one instance

Our on-prem setup was completely ordinary for a large enterprise program that grew over a decade:

On-premise topology: a shared commons repository is published as a Maven dependency and embedded into three product repositories, which build independently and deploy one at a time onto a single AEM instance.

Three product repositories on GitHub, plus a fourth repo the three teams maintained together:

Each repo built its own content packages and each was installed onto the same author and publish instances, independently and sequentially. commons was published as a versioned Maven artifact; the sites consumed it as a dependency. If site-b needed a hotfix at 18:00, site-b deployed. Nothing else moved.

That model is simple, it scales organizationally, and AEM as a Cloud Service takes it away from you.

2. The constraint that forces the redesign

The problem isn't that AEMaaCS "requires a monorepo." It's narrower and more mechanical than that: a Cloud Manager deployment is assembled from a single pipeline repository revision. A pipeline:

  1. clones one Git repository and checks out one branch,
  2. runs one Maven build from the repository root,
  3. collects the resulting content packages into the images that become your environment,
  4. deploys them as a unit.

There is no "install package #2 onto the instance that already has package #1." The environment is rebuilt from the artifacts of a single build, at a single revision. Which means:

If several independently maintained repositories must contribute to the same deployment, they need an explicit assembly mechanism that resolves to one revision.

Three product repos plus a commons repo, all deploying to the same environment, need exactly that: a repository whose contents — or, as it turns out, whose submodule pointers — resolve to that one revision. The whole design problem is: how do you build that assembly mechanism without destroying three independent team workflows?

3. The three ways to get one repository

Option A — Merge everything into a monorepo

Physically move all code into one repo, delete the old ones.

Option B — Adobe's documented copy-forward pattern

Adobe's own guidance describes automation (GitHub Actions or Jenkins) that copies the file tree of each source repository into a designated subdirectory of Cloud Manager's Git repository, with a branch mapping between the two.

Option C — Aggregator repository with Git submodules ← what we chose

The aggregator contains no product code at all. It contains a root pom.xml, shared infrastructure, and a submodule pointer — literally one commit SHA — per product repository.

4. The architecture

Cloud architecture: product repositories feed a GitHub Action that bumps submodule pointers in the aggregator repository, which Cloud Manager deployment pipelines build; pull requests in product repos feed Code Quality pipelines directly.

Two flows, two purposes:

That separation is the part most teams miss, and it is what makes the whole model livable.

5. Anatomy of the aggregator repository

Directory layout of the aggregator repository: root pom.xml, .gitmodules, Cloud Manager settings, shared resources, dispatcher and config folders, and one submodule per product repository.

The root pom.xml

<modules>
  <module>resources</module>
  <module>commons</module>
  <module>site-a</module>
  <module>site-b</module>
  <module>site-c</module>
  <module>dispatcher</module>
</modules>

Order matters: resources and commons first, because everything else depends on them. Each submodule directory contains its own pom.xml with its own all, core, ui.apps, ui.content, ui.config modules — completely unchanged from the on-prem layout. Maven simply walks into them.

The root POM is also where we centralised:

The resources module

A single content-package of type container whose only job is to embed vendor packages once for the whole program:

<embedded>
  <groupId>com.adobe.acs</groupId>
  <artifactId>acs-aem-commons-bundle</artifactId>
  <target>/apps/reactor-packages/application/install</target>
</embedded>

On-prem, each site had shipped its own copy of the same vendor bundles. In one shared deployable, that means the same OSGi bundle installed three times with three possible versions — a guaranteed incident. Hoisting them into one module was one of the highest-value refactors of the whole migration, and it was only possible because we had an aggregator.

.gitmodules

[submodule "site-a"]
	path = site-a
	url = https://github.com/org/site-a.git
	branch = develop

[submodule "commons"]
	path = commons
	url = https://github.com/org/commons.git
	branch = develop

Two things to notice:

  1. The branch key is required by our synchronization model, not by Cloud Manager. It's what git submodule update --remote follows when our sync job advances a pointer. It is not what determines the commit Cloud Manager actually deploys — that's the gitlink (a specific commit SHA) recorded in the aggregator commit itself, regardless of what branch says. Cloud Manager's own git submodule update --init (§8) checks out that recorded SHA and ignores branch entirely.
  2. The .gitmodules file differs per aggregator branch. On the develop branch of the reactor, every submodule tracks develop. On main, every submodule tracks main. That single file is the entire environment-to-branch contract for our sync job. (See §7.)

6. The sync automation

One CI workflow, committed into every product repository, does the whole job. Rather than reproduce our actual file line-for-line — it's internal automation, not something meant for public reuse verbatim — here is the mechanism it implements. It ports directly to GitHub Actions, GitLab CI or Jenkins:

TRIGGER   push to an integration branch (develop, qa, …) in a product repo

SYNC JOB
  1. Check out the current product repo — full history, not a shallow clone
  2. Check out the aggregator repo, authenticated with a write-scoped
     token (the default per-repo CI token cannot push into another repo)
  3. Switch to the matching branch in the aggregator;
     create it if it doesn't exist yet
  4. git submodule sync
  5. git submodule update --remote --recursive
       → moves every submodule pointer to the tip of the branch
         named for it in .gitmodules
  6. Commit "Update submodules to latest <branch>"
       → treat "nothing to commit" as success, not failure
  7. Push the aggregator branch

Step by step

Sequence diagram: a push to a product repository triggers the workflow runner, which clones the aggregator, advances submodule pointers, commits and pushes, which in turn triggers the Cloud Manager pipeline.

Why each step matters

Step
Why it matters
Full-history checkout
Our sync job needs it — to reliably see whether the target branch already exists on the aggregator remote before deciding "checkout or create," and to push cleanly afterward. This is a property of our checkout step, not a Cloud Manager requirement: Cloud Manager's own build clone is a separate operation, and Adobe explicitly recommends shallow submodules there (§8).
Aggregator repo name kept as a CI variable, not hardcoded
The identical workflow file is copy-pasteable into every product repo with zero edits.
A dedicated write-scoped token
The default per-repo CI token is scoped to the repo it runs in and cannot push cross-repo.
"Checkout X, or create X" fallback
Makes a brand-new feature branch self-provisioning in the aggregator. Push a new branch in a product repo and the matching branch appears there on its own.
Credentials injected via Git's URL rewrite, never written into .gitmodules
Keeps secrets out of any file that ever gets committed — Adobe explicitly forbids credentials in submodule URLs.
git submodule sync
Re-applies .gitmodules URLs to local Git config. Without it, a changed URL is silently ignored.
--remote
The key flag: move each pointer to the tip of the tracked branch, not to the last recorded commit. Without it the update command is a no-op by design.
"Nothing to commit" treated as success
git commit exits non-zero on an empty index. Without this guard, every idempotent run turns the job red.

The --remote trade-off you must decide consciously

git submodule update --remote --recursive advances all submodules, not just the one that triggered the run. A push to site-b will also sweep in whatever landed on commons:develop an hour ago.

For an integration branch that is exactly what you want: the reactor's job is "the current state of everything." For a release branch it is dangerous, and you should either scope the update (git submodule update --remote -- site-b) or lock release branches so only deliberate, reviewed pointer bumps happen. We learned this the direct way.

7. Branch topology = environment topology

The mapping is one-to-one and lives in .gitmodules per reactor branch:

Branch mapping: product repository branches sync to identically named aggregator branches, each wired to one environment tier.

On reactor/develop, .gitmodules says branch = develop for every submodule. On reactor/main, it says branch = main. Promotion is therefore normal Git in the product repos — develop → qa → main — and the reactor follows along automatically. Nobody promotes "the reactor"; the reactor is a projection.

Integration branches are moving manifests. Release branches are immutable manifests.

This is the sharpest way to describe what the aggregator actually is: not just a repository, but a bill of materials whose submodule pointers are either moving or frozen, depending entirely on which branch you're looking at.

On develop and qa, our sync job runs git submodule update --remote --recursive on every push, so the pointers are always racing to catch up to whatever's newest in each product repo:

develop (moving manifest)
  site-a   → latest commit on site-a:develop
  site-b   → latest commit on site-b:develop
  commons  → latest commit on commons:develop

That's exactly the property an integration branch needs — "the current state of everything" — and it's exactly why the moving version needs the failure-mode handling in §10: a live, converging system can race, drift, or partially fail to converge.

A release cut wants the opposite property: the same commits that reached QA are the same commits that reach Stage and Prod, full stop.

release/2026.09 (immutable manifest)
  site-a   = a1b2c3d   ← frozen at cut time
  site-b   = 9f8e7d6
  commons  = 4c5d6e7

We get that the practical way: nothing runs --remote against a release branch. No sync job targets it — it isn't in any product repo's on: push: branches: trigger list. Pointers on a release branch move only when someone deliberately re-runs the update for one named submodule (a hotfix), which is a reviewed, single-line diff, not an automatic sweep.

git submodule status on a release branch is therefore not just diagnostic — it's the actual, auditable manifest of what shipped.

Feature branches only cover the submodules that have one

Because of git checkout X || git checkout -b X, a developer working on a spike can push feature/ABC-123 in a product repo, get a matching reactor branch created automatically, and point a dev pipeline at it. That gave us cheap, isolated, full-stack feature environments — something that was effectively impossible on-prem.

The part worth spelling out: pushing feature/ABC-123 to site-a does not automatically put every other submodule on a branch of the same name. If site-b, site-c and commons have no feature/ABC-123 branch, the fallback in .gitmodules for those submodules is whatever branch the reactor's feature/ABC-123 was branched from — typically develop. So a feature environment is really: this one submodule on the feature branch, every other submodule on baseline. That's usually exactly what you want (you're testing one change in the context of everything else at its current state) — but it does mean a feature environment is not automatically isolated across the whole stack, and if your change genuinely spans two product repos, you need matching feature branches in both, or an explicit, reviewed override of the aggregator's .gitmodules for that branch.

8. What Cloud Manager actually does with submodules

Worth stating precisely, because there is contradictory folklore online.

At build time, Cloud Manager clones the pipeline's repository, checks out the configured branch, and — if a .gitmodules file exists in the root directory — runs:

$ git submodule update --init

Note: --init, not --remote. Cloud Manager checks out exactly the commits your .gitmodules-managed pointers record. It never advances them for you. That is precisely why the sync automation in §6 exists: the pointer bump is your job; the checkout is Cloud Manager's.

The documented rules you must respect (Git Submodule Support):

Rule
Detail
.gitmodules location
Root directory of the branch only.
Nesting
Not supported. Only submodules at the root of the branch.
URL form
Adobe repos: exactly https://git.cloudmanager.adobe.com/ProgramName/projectName/. GitHub repos: HTTPS or SSH, must be github.com.
Mixing
You cannot mix an Adobe-repo submodule into a GitHub aggregator, or vice versa. Pick one world.
Credentials
Never in the URL.
Access
GitHub submodules must be reachable by the Adobe GitHub App.
Recommendation
Adobe suggests shallow submodules.

Not on GitHub.com? The version we shipped first

Before our GitHub organization was connected to Cloud Manager directly, none of the above was available to us, and we ran the harder version of this pattern.

Documented git submodule support names exactly two kinds of hosts: Adobe's own git.cloudmanager.adobe.com repositories, and GitHub.com repositories reachable through the Adobe GitHub App. Nothing else appears in that spec. So before the GitHub App was connected, our aggregator's submodules couldn't point at a product repo directly. Instead:

Comparison: without a direct GitHub connection, each product repo must be mirrored into a Cloud Manager-hosted branch before the aggregator's submodule can read it, adding an extra push and an extra remote. With the GitHub App, the aggregator submodule reads the product repo directly.

Each product repo needed its own mirror living inside Cloud Manager's own git hosting, and the aggregator's .gitmodules pointed at that mirror, not at the real repo. The sync workflow grew a second leg: push the product repo's commit to its Cloud-hosted mirror, then bump the aggregator's submodule pointer to the mirror's new commit, then push the aggregator. Two pushes, two remotes — twice the surface for a token to expire or a webhook to quietly stop firing.

Once our GitHub organization was connected directly (the Private Repository flow below), that mirror leg disappeared entirely. The aggregator's submodules could point straight at the real product repos, and the sync workflow collapsed to the single pointer-bump job in §6.

The external-repository story keeps moving, so treat this section as a snapshot, not a spec. When we first shipped this, documented submodule support covered exactly two kinds of hosts — Adobe's own repos and GitHub.com through the Adobe GitHub App — so a product repo hosted anywhere else needed the mirror-and-double-push workaround above. That was a constraint of the implementation at the time, not a permanent architectural requirement.

Separately, Cloud Manager has been actively expanding "external repositories" (Bring Your Own Git) — GitLab (cloud and self-hosted), Bitbucket Cloud, Azure DevOps and GitHub Enterprise Server, connected via a personal access token and a manually configured webhook, no GitHub App required. As of the last time we checked Adobe's live documentation (September 2026), that feature is still scoped to feeding a Full Stack or Frontend pipeline directly, is explicitly documented as unable to back a Configuration pipeline, and the dedicated submodule documentation still names only GitHub.com and Adobe's own repos as valid submodule hosts — GitLab, Bitbucket and Azure DevOps aren't in that spec. We could not find a primary source confirming that has changed, despite it being a plausible next step given how fast this area is moving.

If your product repos aren't on GitHub.com, check Adobe's current submodule and external-repository documentation directly before designing around either answer. Don't take our word, and don't take a search engine's summary of our word either — verify against the live docs, the same way we just did for this paragraph.

Registering the repositories

Add the repo in Program Overview → Repositories → Add Repository → Private Repository, install the Cloud Manager for AEM GitHub App (needs org-owner rights), then prove ownership by committing the generated secret to .well-known/adobe/cloud-manager-challenge on the default branch and clicking Validate (Add Private Repositories). The repo shows a red icon until validated. GitHub.com only — self-hosted GitHub is not supported by this flow (it goes through External Repositories instead, per the caveat above).

We registered five repositories: the reactor, the three product repos, and the shared commons repo. The reactor is what deployment pipelines build. The other four exist in Cloud Manager purely so that pull-request Code Quality pipelines can run where developers actually work.

9. Three artifact types, three pipelines — the payoff

This is the part I would put on a slide if I had one slide.

Because the Dispatcher config (dispatcher/) and the CDN/maintenance config (config/) live in the aggregator repository alongside the code, we could give each of them its own Cloud Manager pipeline. Cloud Manager distinguishes the deployed artifact by pipeline type:

One repository feeds three pipeline types — Full Stack, Web Tier Config and Config — which deploy independently into the same environment.

Pipeline type
Deploys
Typical trigger in our setup
Full Stack
OSGi bundles, ui.apps, ui.content, ui.config, vendor packages
On git change for Dev; manual for QA/Stage/Prod
Web Tier Config
Apache vhosts, rewrites, Dispatcher farms, cache & filter rules
Manual per environment; on git change for a dedicated dev pipeline
Config
CDN rules (auth, purge, request transforms), maintenance tasks
Manual per environment

In practice this gives every environment tier its own small family of pipelines — a Full Stack and a Web Tier Config pipeline per tier at minimum, a Config pipeline wherever CDN or maintenance settings need to move independently, plus one PR-triggered Code Quality pipeline per product repository. The exact count depends on how many tiers you run; the shape — three types, replicated per tier, plus per-repo quality gates — is what matters.

Why this matters more than it sounds.

A Full Stack deployment on a program this size comfortably runs over an hour — it rebuilds and redeploys everything, top to bottom. Under that model, "add one cache-header rule for one vhost" and "ship a quarter's worth of features" cost exactly the same. Teams respond rationally: they batch changes, they avoid touching the Dispatcher, and the config drifts from what anyone intended.

Splitting the pipelines changes the economics. A Dispatcher filter fix, a CDN redirect, a cache TTL adjustment — each is a small, independently reviewable, independently revertible change that ships in minutes without recompiling a single Java class or touching any site's release schedule. Mean time to recovery for edge-layer incidents dropped from "wait for the next deployment window" to "about ten minutes."

Independent pipelines are not independent changes, and it's worth saying that plainly. Splitting the deployment mechanism doesn't split the semantic dependency between layers. An application change that introduces a new URL, selector or response header can require a matching Dispatcher rule and, behind it, a matching CDN rule:

application change → new URL / selector / header
        → Dispatcher rule must follow
              → CDN rule must follow

The three pipelines let you ship each of those mechanically on its own schedule — that's the whole point — but someone still has to know the chain exists and sequence it correctly (usually: code first, then Dispatcher, then CDN, so nothing 404s in between). Splitting the pipelines buys you deployment independence, not change-coordination for free.

Two practical notes:

10. The gotchas — everything that bit us

1. --remote sweeps everything. Covered in §6. Decide per branch class whether that is desirable, and constrain release branches.

2. Concurrent pushes race. Two product repos merging within the same minute both clone the reactor, both commit, and the slower one's push is rejected. Mitigate with GitHub Actions concurrency: groups keyed on the target branch, plus a retry with rebase. A rejected push is not just a red build — it means a pointer bump silently did not land.

3. Submodules are checked out in detached HEAD. After a local git submodule update --init, you are not on a branch. Editing code there and committing produces a commit on no branch, which the next update discards. Developers must work in the product repo clone, not inside the aggregator's submodule directory. Say this out loud in onboarding; write it in the README.

4. Everyone must run git submodule update --init. A fresh clone of the aggregator gives you empty directories and a Maven build that fails with a baffling error. Put it in the README as step one, and consider a prepare script.

5. Shared-dependency version alignment. The moment all sites land in one deployable, two sites pulling two versions of the same OSGi bundle becomes a real runtime conflict rather than a theoretical one. Centralise every shared version in the root <dependencyManagement> and forbid version declarations in submodule POMs.

6. The aggregator's history becomes machine-generated. Ours is a wall of Update submodules to latest develop. That is fine — but it means humans must never rely on the reactor's log for "what changed." The answer always lives in the product repos. git submodule summary between two reactor commits is the tool that bridges the two.

7. PAT lifecycle is a single point of failure. One expired token silently stops all synchronisation; pipelines keep deploying the last known-good pointers and nobody notices for a day. Use a GitHub App installation token if you can, alert on workflow failures, and calendar the expiry.

8. Keep .cloudmanager/java-version and the POM's compiler level in lockstep. They are read by different systems; when they disagree, the build failure message points at neither.

11. What it bought us

12. When not to do this

Be honest about the cost: you are trading a simple repo layout for a distributed system with a synchronisation job in it. That is only worth it when the organisational split is real.

Do it when: you have multiple genuinely independent teams with independent release cadences; the repos already exist and have history worth keeping; different codebases have different compliance or access requirements.

Don't do it when: it is one team and two repos that were split for no particular reason — merge them and move on; or when the codebases are so entangled that every change spans all of them anyway, in which case the submodule pointers just add ceremony to a monorepo you already have.

13. Replication checklist

  1. Create the aggregator repository. Add the root pom.xml with an empty <modules> section, .cloudmanager/java-version, and .cloudmanager/maven/settings.xml.
  2. Register it in Cloud Manager as a private repository; install the Adobe GitHub App; commit .well-known/adobe/cloud-manager-challenge; validate.
  3. Register each product repository the same way — you will want PR Code Quality pipelines on them.
  4. git submodule add -b <branch> <url> <dir> for each product repo, at the root only. Commit the empty directory plus .gitmodules.
  5. Add each directory to <modules> in the root POM, dependencies-first.
  6. Create a resources container package and hoist every vendor/third-party package into it. Remove those embeds from the product repos.
  7. Move dispatcher/ and config/ into the aggregator.
  8. Add the sync workflow to every product repo. Configure the aggregator repo name as a CI variable and the write-scoped token as a secret. Add a concurrency: group.
  9. Replicate the branch set in the aggregator, with a .gitmodules per branch pointing at the matching product branches.
  10. Create pipelines: Full Stack + Web Tier Config + Config per environment tier from the aggregator; Code Quality on PR from each product repo.
  11. Document git submodule update --init and the detached-HEAD rule in the README before anyone else clones it.

Sources

If you are running a multi-repo AEM program on Cloud Service, I'd genuinely like to hear which route you took — aggregator, monorepo, or copy-forward — and what broke. The failure modes are where the real knowledge is.