Maven Profiles vs. Filtering vs. Separate POMs: Choosing How to Handle Environment-Specific Builds
A decision guide for environment-specific Maven builds: when to use profiles, resource filtering, or separate POMs, with a working dev/prod profile example and the commands that prove it resolves correctly.
15 Nov 2025, 02:06 UTC

You need one build that produces a developer-friendly artifact locally and a production-ready artifact in CI — different database drivers, different resource values, maybe different plugin executions — without forking your pom.xml into per-environment copies. Maven gives you three supported ways to do this, and they solve different problems. Picking the wrong one is how teams end up with a prod-pom.xml that drifts out of sync, or a filtering setup that can't actually change a dependency.
The short version: use profiles when the environment changes the build model (dependencies, plugins, properties), use resource filtering when only config file values change, and reserve separate POMs or modules for genuinely divergent deliverables. The rest of this guide compares them and shows a working profile setup with validation commands.
The decision and its constraints
Before choosing, fix your constraints. The common ones:
- One maintainable
pom.xml— no copy-pasted variants that drift. - Activation must be explicit and auditable, especially in CI.
- The number of environments is small (typically dev, prod, maybe staging). If you have many orthogonal variations, no Maven mechanism will save you from combinatorial builds.
- Local and CI builds must resolve the same model for a given profile.
Comparing the three supported options
| Option | What it can change | Strengths | Weaknesses |
|---|---|---|---|
Profiles in pom.xml, activated by -P, property, JDK, OS, or file presence | Dependencies, plugins, plugin configuration, properties, repositories, most of the build model | Single POM; can alter the dependency graph and plugin executions | Implicit activation (by JDK/OS/file) is hard to audit; profile merging order can surprise |
| Resource filtering with properties | Placeholder values inside filtered resource files (e.g., application.properties) | Simple, well-understood, no model changes | Cannot change dependencies or plugin execution; only substitutes text into resources |
| Separate environment POMs or modules | Anything — full isolation | Strong isolation; no merging ambiguity | Duplicates shared configuration; complicates inheritance and aggregation; heavy for small deltas |
The trade-off that decides most cases: filtering is the right tool when the artifact is identical and only its bundled configuration differs — but many teams reach for it when they actually need a different dependency (say, H2 for dev, PostgreSQL for prod), which filtering cannot express. Conversely, profiles are overkill if all you need is a different JDBC URL in a properties file. Separate POMs only win when the environments produce genuinely different deliverables, not the same app with different wiring.
A concrete profile implementation
Assume Maven 3.8+ and a single-module project. The goal: H2 on the classpath for dev, PostgreSQL for prod, and tests skipped only in prod (a questionable practice in general, but a common requirement — shown here because it demonstrates plugin-property control).
<profiles>
<profile>
<id>dev</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<skipTests>false</skipTests>
</properties>
<dependencies>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
<profile>
<id>prod</id>
<properties>
<skipTests>true</skipTests>
</properties>
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
</profiles>Two things to notice. First, activeByDefault on dev is a convenience for local work — but it is deactivated as soon as any other profile in the same POM is activated, which is a frequent source of confusion. Second, note that activeByDefault profiles are also disabled when a profile is activated via -P, so mvn -Pprod gives you prod only, not dev+prod. That is usually what you want, but verify it rather than assume it.
Validating the result
Run these from the project root; no special permissions needed. Never trust the profile XML alone — audit the merged model.
mvn help:active-profilesandmvn help:active-profiles -Pprod— confirms which profiles are actually active with and without explicit activation. Expected:devby default,prodonly when requested.mvn help:effective-pom -Pprod— inspects the fully merged model. Check that the PostgreSQL dependency appears and H2 does not, and thatskipTestsresolved totrue.mvn -Pdev dependency:treeandmvn -Pprod dependency:tree— confirms environment-specific artifacts appear only where intended in the resolved graph, including transitives.mvn -Pprod clean verify— the end-to-end check that the prod build actually succeeds with its own model.
In CI, add an explicit guard so a missing -P flag fails loudly instead of silently building the dev model:
mvn help:active-profiles | grep -q "prod" || { echo "prod profile not active"; exit 1; }Run this in your CI job's shell before the main build. The risk it mitigates is real: a plugin declared only inside a profile runs only when that profile is active, so a misconfigured CI job can skip steps without any error.
Limitations and pitfalls
- Merging order surprises. Profiles from parent POMs,
settings.xml, and the command line all merge; later or external definitions can override earlier ones.help:effective-pomis the only reliable source of truth. - Combinatorial explosion. If you find yourself activating
-Pprod,mysql,linux,java17, profiles are the wrong abstraction — that's four orthogonal axes. Keep profiles few, named by purpose, and document their activation conditions in the README. - Profiles don't follow the artifact. A published artifact's consumers don't see your profiles; anything environment-specific baked into the build must be resolved before deploy. If different environments need different runtime behavior from the same artifact, that's an application-configuration problem (environment variables, config servers), not a Maven problem.
settings.xmlprofiles reduce reproducibility. A profile activated from a developer's local settings file won't exist in CI. Prefer POM-local profiles with explicit-Pactivation for anything the build depends on.
The practical rule: start with filtering if only values change, graduate to profiles when the model changes, and only split POMs when the deliverables themselves diverge. Then prove the choice with help:effective-pom and dependency:tree per profile — those two commands catch nearly every profile mistake before it reaches CI.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.