Skip to content

Configuration baselines

Fifty projects drift. One was created before you settled on squash merges, someone unprotected main to fix something and never put it back, and two never had an approval rule at all. There is no answer to “are these the same?” short of opening fifty tabs.

A baseline is a JSON file saying what those projects should have in common, and three commands that read it:

CommandWrites?What it does
simply gitlab config exportnoEmits a config from a project you already like
simply gitlab config plannoReports, per project, what differs from the baseline
simply gitlab config applyyesConverges each project onto the baseline

Do not write the file by hand. Export the project that already looks right and delete what you do not care about:

Terminal window
simply gitlab config export --project group/api > baseline.json

The output is deliberately verbose — every supported key, not a curated subset — because deleting a line is easier than discovering that a key exists. Variables come out as metadata only: keys, scopes, and flags, never values.

Project features are exported as access levels rather than on/off booleans:

FeatureKey
Analyticsanalytics_access_level
Environmentsenvironments_access_level
Feature flagsfeature_flags_access_level
Infrastructureinfrastructure_access_level
Issuesissues_access_level
Merge requestsmerge_requests_access_level
Model experimentsmodel_experiments_access_level
Model registrymodel_registry_access_level
Monitormonitor_access_level
Package registrypackage_registry_access_level
Pagespages_access_level
Pipelinesbuilds_access_level
Releasesreleases_access_level
Repositoryrepository_access_level
Requirementsrequirements_access_level
Security and compliancesecurity_and_compliance_access_level
Snippetssnippets_access_level
Wikiwiki_access_level

Each takes disabled, private (project members only), or enabled (everyone with access to the project). package_registry_access_level and pages_access_level also take public.

Export no longer writes issues_enabled, wiki_enabled, or packages_enabled, which GitLab has deprecated. A baseline that declares them still works, but replace them rather than declaring both, since each pair controls the same feature.

GitLab refuses a repository_access_level lower than merge_requests_access_level or builds_access_level. Keep the three together when you trim an export: a baseline that tightens the repository alone can fail on a project where the other two are still open.

A key the instance does not report is left out of the export. Written in by hand, the same key shows as a change on every plan, because there is nothing on the instance to compare it with.

The exported file targets only the project it came from. Widen it on purpose:

{
"version": 1,
"targets": {
"groups": ["platform"],
"exclude": ["platform/sandbox", "platform/archived-*"]
},
"project": {
"settings": {
"merge_method": "ff",
"squash_option": "default_on",
"only_allow_merge_if_pipeline_succeeds": true
},
"protectedBranches": [{ "name": "main", "allow_force_push": false }],
"approvalRules": [{ "name": "Two maintainers", "approvals_required": 2, "usernames": ["alice", "bob"] }]
}
}

targets.groups expands through subgroups. exclude takes a trailing * to drop a whole subtree. A project the token cannot see is a hard error, not a silent omission — a baseline that quietly skipped six of fifty would leave those six wrong with nobody knowing to look.

Terminal window
simply gitlab config plan --config ./baseline.json
Targets: 50 project(s), 1 group(s).
platform/api
settings
~ merge_method merge → ff
~ only_allow_merge_if_pipeline_succeeds false → true
protectedBranches
~ main.allow_force_push true → false
- legacy (not pruned; set "prune": true on this section to remove it)
platform/worker
✓ matches the baseline
50 target(s): 31 match, 19 differ.

Three things in that output are worth understanding.

Only what you declared is compared. A project with forty settings the config never mentions shows nothing for them. That is what makes a baseline safe to point at projects that already exist: the blast radius is the set of keys you wrote down.

Drift you are not fixing is still shown. - legacy is a protected branch that exists but is not in the config. By default nothing is deleted, and the line says so. Seeing what you are choosing not to correct is most of the value of running this across fifty projects.

A section can come back unsupported. Approval rules, approval settings, and push rules are Premium/Ultimate. On an instance without them the section reports that and the rest still converges.

Terminal window
simply gitlab config apply --config ./baseline.json --dry-run # prints the plan, sends nothing
simply gitlab config apply --config ./baseline.json

Apply executes exactly what the plan showed — the two are computed in one pass, so a preview is a guarantee rather than an approximation. A second run against a converged project sends no requests at all.

Sections are applied in a fixed order: settings, protected branches, protected tags, approval settings, approval rules, push rules, badges. That order exists because an approval rule scoped to main needs main protected before it can reference it.

One project failing does not abandon the other forty-nine. There is no transaction across GitLab endpoints, so a project that fails part-way keeps the sections already applied; the summary says where it stopped, and plan afterwards shows what is left.

overrides is keyed by project path:

"overrides": {
"platform/legacy-api": {
"project": {
"settings": { "merge_method": "merge" },
"approvalRules": [{ "name": "Two maintainers", "approvals_required": 1 }]
}
}
}

Lists merge by a natural key — name for branches, tags, rules, and badges; key plus environment_scope for variables — so an override adjusts one entry rather than restating the array. The rule above changes one number and inherits the usernames from the baseline.

Set "$replace": true on a section to swap the list wholesale instead, for the project that needs genuinely different rules rather than adjusted ones.

By default the config adds and corrects; it never deletes. When you are ready for a section to be authoritative, opt in:

"protectedBranches": {
"prune": true,
"entries": [{ "name": "main", "allow_force_push": false }]
}

Now an undeclared protected branch is removed. Adopt this section by section, after plan has shown you what it would take away — a first run against fifty existing projects with everything pruning could remove protections that predate the config.

Terminal window
simply gitlab config plan --config ./baseline.json --fail-on-drift

Exits 1 when anything differs, so a scheduled job tells you something drifted without anyone reading the output. --json returns the whole plan as structured data if you would rather track it over time:

Terminal window
simply gitlab config plan --config ./baseline.json --json | jq '.summary'

badges declares a project’s own badges by name. GitLab’s placeholders work in both URLs, so one entry fits every project in the baseline:

"badges": [
{
"name": "Pipeline",
"link_url": "https://gitlab.example.com/%{project_path}/-/pipelines",
"image_url": "https://gitlab.example.com/%{project_path}/badges/%{default_branch}/pipeline.svg"
}
]

A new badge needs both URLs; plan reports the project as failed if one is missing.

Badges inherited from a parent group are ignored, because they belong to the group. A badge with no name is left out of an export, since a baseline matches badges by name. If it is still on a project, plan shows it as (unnamed badge <id>), and turning on prune removes it.

If two badges on a project share a name, only the last one GitLab lists is compared.

The config can declare which variables must exist and what flags they carry. plan reports the ones that are missing or wrong; apply does not touch them.

That is the API, not caution. Creating a variable requires a value, and so does updating one — even to change masked from false to true. A variable created as masked_and_hidden will not return its value at all. There is no honest way to reconcile secrets from a file that, by design, holds none.

Knowing that six projects are missing DEPLOY_ENV is still the question people actually ask; setting it is a person’s job.

The MCP server exposes gitlab_config_plan and gitlab_config_export, so you can ask an agent “which of our projects are missing the two-approval rule?” and get an answer.

There is deliberately no tool that applies a baseline. Changing settings across fifty projects is a deployment, not a tool call, and belongs to a person with the plan in front of them. An agent that should apply one can run the CLI, where it is a shell command you can see and approve. See the MCP server guide.