Every time a developer runs mvn deploy from their local machine, the deployment carries that developer's Maven settings, their JDK version, their local environment, and whatever credentials they happen to have authenticated with. One misconfigured property file is the difference between a clean release and a production incident on a Friday evening.
CI/CD is not optional in 2026. The question is what shape it takes. This guide walks through the modern shape: a GitHub Actions pipeline that authenticates to Anypoint Platform with a Connected App, builds on JDK 17 against Mule 4.9, runs MUnit with a coverage gate, and deploys to CloudHub 2.0. The workflow itself lives once in a central repository as a reusable workflow, and every API repository in the estate calls it. Production deployments wait behind a GitHub Environment with required reviewers. Anypoint CLI v4 handles the things the Mule Maven Plugin cannot reach.
The article covers two halves. The first: the modern default (Connected Apps, CloudHub 2.0, reusable workflows, JDK 17). The second: the legacy path (CloudHub 1.0, username/password authentication, Mule Maven Plugin 3.x) and what migrating off it looks like. Most existing clients still have one foot in that world.
The 2026 stack
Four decisions define a modern MuleSoft CI/CD pipeline. Each is a deliberate move away from how pipelines were built in 2020, and each is the default for any new programme started today.
Connected App, not username and password
Connected Apps are MuleSoft's machine-to-machine identity model, configured under Access Management with a fixed set of scopes and a client_id plus client_secret. The Mule Maven Plugin 4.x accepts Connected App credentials directly. Username and password authentication is being progressively deprecated and does not survive an Anypoint Platform tenant that has MFA enabled, which is most of them after 2024. Even where it still works, Salesforce's published guidance is to move new pipelines to Connected Apps.
CloudHub 2.0 for new applications
CloudHub 2.0 uses Kubernetes underneath, supports finer-grained vCore sizing (0.1 / 0.2 / 0.5 vCore), allows multiple replicas per application without the worker-cluster pricing penalty, and exposes a simpler deployment model via the cloudhub2Deployment XML block. CloudHub 1.0 is still in support but is on a documented deprecation path. New applications go to CloudHub 2.0 unless there is a specific connector or runtime feature that has not landed on the new platform.
JDK 17 and Mule 4.9
Mule 4.9 (released 2025) made JDK 17 the recommended default. The published release uses a java17-tagged artifact identifier. The CI runner must install JDK 17 from a TCK-certified distribution (Temurin is the Ampleshift default) and the pom.xml must declare app.runtime as 4.9-java17 or later.
Reusable workflows in a central repository
Copy-pasting the same 80-line workflow file into thirty API repositories is the worst way to scale a CI/CD practice. The right pattern: a reusable workflow defined once in a central repository, called by every API repo. Updates ship once; every API picks them up at the next build. GitHub's workflow_call trigger is the mechanism.
Ampleshift CI/CD baseline
Connected App per environment, CloudHub 2.0, JDK 17, Mule 4.9, one reusable workflow per organisation, Anypoint CLI v4 for advanced operations, GitHub Environments for production approval. Anything new diverges from this for a documented reason, or not at all.
Prerequisites
| Requirement | Details |
|---|---|
| Anypoint Platform account | Access Management permission to create Connected Apps. CloudHub 2.0 entitlement on at least one environment. |
| GitHub repository | Private repo on a paid plan, or a public repo on any plan. Free private repos do not have GitHub Actions or Environments. One central CI/CD repository plus one per API. |
| MuleSoft Nexus credentials | Required to resolve Mule Enterprise dependencies. Raise a MuleSoft support ticket if your organisation has a subscription but no Nexus credentials. |
| Mule Maven Plugin | Version 4.1.1 or later for CloudHub 2.0 with the cloudhub2Deployment block. The 3.x line works only for CloudHub 1.0. |
| Anypoint CLI v4 | Optional for build and deploy; required for operations beyond the Mule Maven Plugin. Install: npm install -g anypoint-cli-v4 |
| Branch protection | Required reviewers on main and uat branches. Status check requirement: the build-test job must pass before merge. Set in Repository Settings before the first pipeline run. |
Step 1: set up the Connected App with least-privilege scopes
One Connected App per environment is the Ampleshift default. The Sandbox app deploys to Sandbox, the UAT app deploys to UAT, the Production app deploys to Production. Sharing one app across environments works but produces a blast radius that is hard to defend during an audit.
Sign in to Anypoint Platform, navigate to Access Management > Connected Apps, and click Create App. Choose App acts on its own behalf (client credentials). Add scopes scoped to the business group and the target environment. The minimum scopes for a CloudHub 2.0 deployment pipeline:
- Exchange Contributor (publish to Exchange when the build artefact is an Exchange asset)
- View Environment, View Organization, Profile
- Create Applications, Delete Applications, Read Applications
- Manage Application Data (set application properties at deploy time)
- Manage APIs Configuration (only if the pipeline updates API Manager)
After saving, copy the Client ID and the Client Secret. The secret is shown once; if you lose it, regenerate. Store the values directly into GitHub Secrets at the environment level (covered in Step 2), not at the repository level: a Production Connected App secret at the repository level leaks into Sandbox and UAT runs.
CRITICAL: never reuse a Production Connected App in a non-production pipeline
Three environments, three Connected Apps. The Sandbox pipeline must not be able to authenticate as the Production identity, even by accident. This is the single highest-value control you can set in the first hour of any new CI/CD programme.
Step 2: configure repository secrets, variables, and environments
GitHub has three places to put configuration: repository secrets, repository variables, and environment-scoped secrets and variables. The default mistake is to put everything at the repository level.
Environment-scoped secrets (one per env): Create three GitHub Environments: sandbox, uat, prod. On each environment add the Connected App credentials and any environment-specific values. Environment secrets are only available to workflow runs that target that environment.
# Per environment (Settings > Environments > <env-name> > Add secret)
ANYPOINT_CLIENT_ID # Connected App client ID for this environment
ANYPOINT_CLIENT_SECRET # Connected App client secret for this environment
# Environment variables (non-secret, but env-specific)
ANYPOINT_ENV # 'Sandbox' | 'UAT' | 'Production'
CLOUDHUB_REGION # e.g. 'eu-west-1', 'us-east-2'
REPLICAS # e.g. '1' for sandbox, '2' for prodRepository-scoped secrets (shared across envs): Secrets that are genuinely shared across environments stay at the repository level. The Nexus credentials are the canonical case.
# Repository (Settings > Secrets and variables > Actions > Secrets)
NEXUS_USERNAME # MuleSoft Enterprise Nexus username
NEXUS_PASSWORD # MuleSoft Enterprise Nexus password
ANYPOINT_ORG_ID # Organisation ID (constant across envs)
# Repository variables
APP_NAME # Base application name (env suffix added at deploy)
MULE_RUNTIME_VERSION # e.g. '4.9.0' (also pinned in pom.xml)TIP: required reviewers on the prod environment
On the prod GitHub Environment, configure Required reviewers and set the deployment-branch rule to main only. The pipeline blocks at the deployment step until a named approver clicks Approve. This is the simplest production approval gate in the industry and it costs nothing to configure.
Step 3: author the Maven settings.xml for the runner
The Mule Maven Plugin needs Maven to know how to reach the MuleSoft Enterprise Nexus and Anypoint Exchange. Local developers configure this in their ~/.m2/settings.xml. The CI runner uses a settings.xml committed to the repository under .maven/settings.xml. Credentials come from environment variables, never inline.
<!-- .maven/settings.xml -->
<settings>
<servers>
<server>
<id>MuleRepository</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>anypoint-exchange-v3</id>
<username>~~~Client~~~</username>
<password>${env.ANYPOINT_CLIENT_ID}~?~${env.ANYPOINT_CLIENT_SECRET}</password>
</server>
</servers>
</settings>The anypoint-exchange-v3 username/password format requires the Connected App as username ~~~Client~~~ with the password formatted as <client_id>~?~<client_secret>. The triple-tilde and ~?~ separator are the documented convention for client-credentials auth against Exchange.
Step 4: configure the pom.xml for Connected App and CloudHub 2.0
The pom.xml does three things: pins the Mule runtime, configures the Mule Maven Plugin for CloudHub 2.0 with Connected App authentication, and declares the MUnit Maven Plugin with the coverage gate.
Properties and runtime pinning:
<properties>
<app.runtime>4.9-java17</app.runtime>
<mule.maven.plugin.version>4.1.1</mule.maven.plugin.version>
<munit.version>3.3.0</munit.version>
<java.version>17</java.version>
</properties>Mule Maven Plugin with CloudHub 2.0 deployment:
<plugin>
<groupId>org.mule.tools.maven</groupId>
<artifactId>mule-maven-plugin</artifactId>
<version>${mule.maven.plugin.version}</version>
<extensions>true</extensions>
<configuration>
<cloudhub2Deployment>
<uri>https://anypoint.mulesoft.com</uri>
<environment>${env.ANYPOINT_ENV}</environment>
<muleVersion>4.9.0</muleVersion>
<releaseChannel>NONE</releaseChannel> <!-- pin runtime exactly -->
<applicationName>${app.name}-${env.ANYPOINT_ENV}</applicationName>
<replicas>${env.REPLICAS}</replicas>
<vCores>0.1</vCores>
<connectedAppClientId>${env.ANYPOINT_CLIENT_ID}</connectedAppClientId>
<connectedAppClientSecret>${env.ANYPOINT_CLIENT_SECRET}</connectedAppClientSecret>
<connectedAppGrantType>client_credentials</connectedAppGrantType>
</cloudhub2Deployment>
</configuration>
</plugin>CRITICAL: releaseChannel NONE pins the runtime exactly
releaseChannel set to NONE with an explicit muleVersion means CloudHub 2.0 deploys exactly that runtime, no auto-upgrade. The alternative (EDGE, LTS) lets MuleSoft choose patch versions for you. Fine for sandboxes; dangerous for production. Pin in production; allow drift in sandbox if you want.
Step 5: build the reusable workflow in a central repository
Stand up one central repository (the Ampleshift convention is org-name/ci-cd-workflows) that owns the reusable workflow. Every API repository in the estate calls it via workflow_call. Updates to the build, test, and deploy logic ship from one place. This is the single biggest scaling decision in a multi-API MuleSoft estate.
# .github/workflows/mulesoft-cicd.yml in the central CI/CD repo
name: MuleSoft CI/CD (reusable)
on:
workflow_call:
inputs:
app_name: { type: string, required: true }
target_env: { type: string, required: true } # sandbox|uat|prod
mule_version: { type: string, default: '4.9.0' }
secrets:
ANYPOINT_CLIENT_ID: { required: true }
ANYPOINT_CLIENT_SECRET: { required: true }
NEXUS_USERNAME: { required: true }
NEXUS_PASSWORD: { required: true }
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
cache: maven
- name: Build and run MUnit with coverage
run: mvn -B clean verify -s .maven/settings.xml
env:
NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
ANYPOINT_CLIENT_ID: ${{ secrets.ANYPOINT_CLIENT_ID }}
ANYPOINT_CLIENT_SECRET: ${{ secrets.ANYPOINT_CLIENT_SECRET }}
deploy:
needs: build-test
runs-on: ubuntu-latest
environment: ${{ inputs.target_env }} # ties to GitHub Environment with approval
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: 'temurin', java-version: 17, cache: maven }
- name: Deploy to CloudHub 2.0
run: |
mvn -B deploy -DmuleDeploy -DskipTests \
-s .maven/settings.xml \
-Dapp.name=${{ inputs.app_name }}
env:
ANYPOINT_ENV: ${{ vars.ANYPOINT_ENV }}
REPLICAS: ${{ vars.REPLICAS }}
ANYPOINT_CLIENT_ID: ${{ secrets.ANYPOINT_CLIENT_ID }}
ANYPOINT_CLIENT_SECRET: ${{ secrets.ANYPOINT_CLIENT_SECRET }}
NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}Step 6: call the reusable workflow from each API repository
With the central workflow in place, each API repository's own .github/workflows file shrinks to a few lines. Branch-to-environment routing happens here. The pattern below routes develop to sandbox, uat to UAT, and main to production.
# .github/workflows/ci.yml in each API repo
name: API CI/CD
on:
push:
branches: [ 'develop', 'uat', 'main' ]
pull_request:
branches: [ 'develop', 'uat', 'main' ]
jobs:
pipeline:
uses: org-name/ci-cd-workflows/.github/workflows/mulesoft-cicd.yml@v1
with:
app_name: customer-experience-api
target_env: ${{ github.ref == 'refs/heads/main' && 'prod' || github.ref == 'refs/heads/uat' && 'uat' || 'sandbox' }}
secrets:
ANYPOINT_CLIENT_ID: ${{ secrets.ANYPOINT_CLIENT_ID }}
ANYPOINT_CLIENT_SECRET: ${{ secrets.ANYPOINT_CLIENT_SECRET }}
NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}TIP: pin the reusable workflow to a tag, not a branch
@v1 (a tag) is reproducible. @main (a branch) is not: an API's build can break because somebody pushed an incompatible change to the central repo. Tag the reusable workflow, bump the tag deliberately, communicate the upgrade. Same discipline as semantic versioning for any other shared dependency.
Step 7: run MUnit with coverage gates
MUnit produces coverage reports as a side effect of running mvn verify. Three thresholds matter and they are not the same.
| Threshold | Target | What it measures |
|---|---|---|
| requiredFlowCoverage | 100% | Every flow has at least one MUnit test that executes it. Easy to hit; the strongest single coverage signal. |
| requiredResourceCoverage | 80% | Percentage of XML configuration files exercised by tests. Catches whole-file branches that have no tests at all. |
| requiredApplicationCoverage | 80% | Percentage of message processors. Harder to hit; the realistic signal of test depth. |
Set failBuild to true on all three. A failing coverage threshold should break the build the same way a failing test does. The argument against this is always “we will fix the coverage later” and later never arrives.
Step 8: wire Anypoint CLI v4 for what Maven cannot do
The Mule Maven Plugin handles build, package, and deploy. It does not handle: updating API Manager with the deployed application's URL, publishing or promoting Exchange assets across organisations, querying Runtime Manager for deployed application state, or rotating client credentials on contracts. Anypoint CLI v4 covers all of these.
- name: Install Anypoint CLI v4
run: npm install -g anypoint-cli-v4
- name: Update API Manager with deployed URL
run: |
appName=$(mvn help:evaluate -Dexpression=project.name -q -DforceStdout)
appId=$(anypoint-cli-v4 runtime-mgr:application:list --output json \
| jq -r '.[]|select(.name=='''$appName''').id')
appUri=$(anypoint-cli-v4 runtime-mgr:application:describe --output json "$appId" \
| jq -r '.target.deploymentSettings.http.inbound.publicUrl')
anypoint-cli-v4 api-mgr:api:edit --uri "$appUri" "${{ vars.API_MANAGER_ID }}"
env:
ANYPOINT_CLIENT_ID: ${{ secrets.ANYPOINT_CLIENT_ID }}
ANYPOINT_CLIENT_SECRET: ${{ secrets.ANYPOINT_CLIENT_SECRET }}
ANYPOINT_ORG: ${{ secrets.ANYPOINT_ORG_ID }}
ANYPOINT_ENV: ${{ vars.ANYPOINT_ENV }}Step 9: production approval gates with GitHub Environments
A pipeline that auto-deploys to production is faster than one that does not. It is also one bad merge away from a service-impact incident. The middle ground: automatic deployment to sandbox and UAT, with production gated behind a named human approval. GitHub Environments deliver this at no cost.
Configure the prod environment: Settings > Environments > New environment > Name: prod. Add Required reviewers (between 1 and 6). Set deployment branches to main only. Add the environment-scoped Connected App secrets here, not at the repository level.
When the reusable workflow's deploy job declares environment: prod, GitHub pauses the run at the start of that job. A banner appears in the run page asking the named reviewers to approve. The deployment does not start until at least one reviewer clicks Approve. The approver's identity is captured in the run history.
NOTE: reviewers must be repository collaborators
GitHub Environments require approvers to have at least Read access to the repository. For multi-org setups where the on-call rotation lives in a different organisation, add them as outside collaborators with Read access. Giving the on-call team Write access to gain approval rights widens the blast radius unnecessarily.
Step 10: migration path from CloudHub 1.0 and username/password
Most existing clients still have one foot in the legacy stack: CloudHub 1.0 workers, the Mule Maven Plugin 3.x line, username/password authentication, and a pipeline assembled when JDK 8 was the default. None of this is broken today. All of it is on a deprecation timeline.
What the legacy stack looks like:
- Mule Maven Plugin 3.5.x to 3.8.x with the
cloudHubDeploymentblock (CloudHub 1.0) - Authentication via
<username>and<password>tags readingPLATFORM_USERNAME/PLATFORM_PASSWORDenv vars - Mule 4.4.0 or 4.6.0, JDK 8 or JDK 11
- Worker-based sizing (MICRO, SMALL) instead of CloudHub 2.0's replicas and vCores
- Pipeline files copy-pasted across repositories rather than reused
Migration in four steps:
Step 1: Create one Connected App per environment alongside the existing username/password setup. Run both auth methods in parallel for two weeks. Verify the Connected App works end to end before retiring the credentials.
Step 2: Upgrade the Mule Maven Plugin to 4.1.1+ on a single non-critical API. Switch the cloudHubDeployment block to cloudhub2Deployment. Deploy to CloudHub 2.0 in Sandbox. Validate behaviour, including custom properties, secure properties, and connector configurations.
Step 3: Bump the runtime: app.runtime to 4.9-java17 and the runner JDK to 17. Re-run the MUnit suite. Classloader and date/time API changes between JDK 8/11 and 17 are the most common failure points.
Step 4: Extract the workflow logic into the central reusable workflow. Migrate one repository at a time. Decommission the per-repo workflow files as each repository is migrated.
TIP: do not migrate every API at once
Migrate two or three lower-risk APIs first, learn what breaks, then write the migration playbook for the rest. The team migrating last benefits most; the team migrating first absorbs the unknowns. Stagger deliberately.
Troubleshooting and common pitfalls
| Symptom | Root cause and fix |
|---|---|
| 401 Unauthorized on deploy | Connected App scopes too narrow, or scoped to the wrong business group/environment. Review scopes from Step 1. Most commonly missed: Manage Application Data. |
| 404 on anypoint-exchange-v3 | Wrong Exchange version ID in settings.xml. Use anypoint-exchange-v3, not v2. The v2 endpoint returns 404 for client-credentials operations. |
| Passes locally, fails on runner | JDK mismatch. The runner is probably on JDK 11 while the developer is on JDK 17. Pin actions/setup-java@v4 with java-version: 17 explicitly. |
| MUnit timezone assertion failures | Local TZ is non-UTC; the runner is UTC. Freeze test inputs in UTC or set TZ: 'UTC' in the workflow env block. |
| CH2 deploy hangs at “application starting” | Application failing to start at runtime. The plugin exits success at deploy submission, not on healthy state. Add a post-deploy step that polls anypoint-cli-v4 runtime-mgr:application:describe until status == STARTED. |
| Reusable workflow change broke 20 APIs | Callers used @main instead of @v1. Tag the reusable workflow and point callers at the tag. Treat the workflow's tag like a semver release for a library. |
| Production deployed without approval | Either the prod GitHub Environment has no required reviewers configured, or the deploy job did not declare environment: prod. Both must be in place. |
| Nexus 403 Forbidden | Subscription expired, credentials wrong, or entitlement does not include Enterprise repository access. Raise a MuleSoft support ticket. |
What you have built
A GitHub Actions pipeline for MuleSoft that is opinionated, scalable, and aligned with where Salesforce is pushing the platform: Connected Apps for authentication, CloudHub 2.0 as the deployment target, JDK 17 with Mule 4.9 on the runner, MUnit with coverage gates that break the build, a reusable workflow in a central repository, Anypoint CLI v4 for operations Maven cannot reach, and GitHub Environments for production approval.
The single most important operational habit is the per-environment Connected App. Three identities, three secret scopes, three GitHub Environments. Get this right in the first hour and every other decision becomes safer. Get it wrong and a Sandbox pipeline run can authenticate as the Production identity, which is the most common cause of avoidable production incidents in MuleSoft CI/CD reviews.
Key takeaways
- Connected Apps, not username/password. One Connected App per environment, scopes pruned to least privilege.
- CloudHub 2.0 with the
cloudhub2Deploymentblock in pom.xml,releaseChannelpinned toNONEin production. - JDK 17 and Mule 4.9-java17 as the default. Mule 4.4 and JDK 11 are legacy.
- One reusable workflow in a central repo, called by every API via
workflow_call. Tag the workflow; never call@main. - MUnit coverage thresholds that break the build. Flow coverage 100%, application and resource coverage 80%.
- Anypoint CLI v4 for API Manager updates, Exchange operations, and Runtime Manager queries.
- GitHub Environments with required reviewers for production. Environment-scoped secrets for each Connected App.
- Migrate the legacy stack deliberately, two or three APIs at a time.
This pipeline pattern is part of the Ampleshift CI/CD accelerator baseline, one of the reasons our programmes reach production with consistent, auditable delivery pipelines from day one.

