How to Continue Dev Config Across Environments Without Overwrite
Keeping your development configuration consistent across staging, testing, and production can be a nightmare, especially when automated pipelines overwrite settings. This guide shows how to continue dev config safely across environments without losing custom tweaks.
The Overwrite Dilemma: Why Dev Config Gets Erased in Multi‑Env Deploys
Common pitfalls in CI/CD pipelines
When you first wire up a CI/CD pipeline you tend to think of it as a glorified copy‑and‑paste operation: fetch the source, run the build, push the artifact, and the job is done. The pipeline quickly becomes the single authority on what ends up on the target environment, and that authority can silently discard the little‑but‑crucial tweaks you made while developing locally.
- Hard‑coded configuration steps – A step that runs
cp config/default.yml config/production.ymlbefore each deploy. The command is harmless when you only have adefault.yml, but as soon as a developer adds adev.local.ymlwith feature flags, the copy overwrites it every time. - One‑size‑fits‑all build artefacts – Building a Docker image with
--build-arg ENV=prodforces the Dockerfile to copy the sameapplication.propertiesinto every container, regardless of the environment it will run in. The container that lands in a test cluster now carries production defaults. - Configuration drift in secret stores – Many teams store environment variables in a vault and then inject them with a script like
envsubst < config/template.env > .env. If the template is missing a key that was added locally, the resulting.envfile simply drops that key, leaving the service in a half‑configured state. - Unconditional clean‑up phases – A “clean workspace” step that runs
git clean -fdxbefore the build is great for reproducibility, but it also removes any untracked files that a developer might have placed in the repository for local testing. Those files never make it into the artifact. - Skipping merge‑conflict markers – When a merge conflict is resolved automatically by the CI server (e.g.,
git merge --strategy=ours), the resulting file can carry only the base version, wiping out any environment‑specific overrides that were added in the feature branch.
Notice the pattern: each of those steps assumes a static, immutable configuration. The pipeline treats config.yml as a monolith and never asks “what if the developer already has a richer file?”. The result is an environment that looks exactly like the one defined in the pipeline, not the one tweaked during development.
pipeline {
agent any
stages {
stage('Prepare') {
steps {
// Pull in the base config that ships with the repo
sh 'cp src/main/resources/application.yml target/'
// Overwrite with a CI‑generated version
sh 'envsubst < ci/config/template.yml > target/application.yml'
}
}
// other stages…
}
}
If a developer added feature.toggle=true to src/main/resources/application.yml for a local experiment, the cp command copies it into target/ only to have it instantly replaced by the templated version. The developer’s intent never reaches the next environment, and the next time the pipeline runs the missing flag isn’t even on the radar.
These pitfalls are insidious because the pipeline doesn’t throw an error. It simply produces a clean, predictable artifact—just the way the pipeline designer intended. The cost is paid later when a QA engineer encounters a missing feature flag, or when a performance test fails because a logging level was forced to INFO instead of the developer’s DEBUG setting.
How default config files dominate custom settings
- Default config bundled with the application (
application.yml,settings.py,.env). - Environment‑specific file (
application-prod.yml,settings/production.rb,.env.production). - System environment variables.
When a CI/CD step unconditionally rewrites the default file, it collapses the entire hierarchy into a single layer. The custom file you added for development is never consulted because the framework still believes it is dealing with the “default” layer.
# src/main/resources/application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
username: sa
password:
# src/main/resources/application-dev.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/devdb
username: dev_user
password: secret
# CI step (pseudo‑code)
cat > src/main/resources/application.yml <<EOF
spring:
datasource:
url: jdbc:postgresql://prod-db.example.com:5432/proddb
username: prod_user
password: ${prod_db_password}
EOF
When the same artifact lands in the staging environment, the default file already contains the production URL. Even if the dev profile is still active, Spring Boot will load application-dev.yml on top of the new defaults, but the production URL wins for any property that isn’t overridden. If application-dev.yml only defines url and leaves username blank, the default’s username (the production one) remains in effect.