Home

Move your build settings into xcconfig files

Build settings edited in Xcode’s UI live in project.pbxproj — a generated file that is unreadable, unreviewable, and produces the worst merge conflicts in iOS development.

.xcconfig files are plain text, diff cleanly, and can be reviewed. Moving to them takes an afternoon.

The basics

// Base.xcconfig
PRODUCT_BUNDLE_IDENTIFIER = com.example.app
MARKETING_VERSION = 2.4.0
SWIFT_VERSION = 6.0
IPHONEOS_DEPLOYMENT_TARGET = 17.0
DEVELOPMENT_TEAM = ABC123XYZ

Assign it in Project → Info → Configurations, per configuration. Settings in the project file still override the xcconfig, so the migration is: move a setting into the file, then delete it from the UI. Until you delete it, the file is being ignored.

Tip

The setting is bold in Xcode’s build settings inspector when it is overridden at that level. After moving something to an xcconfig, check the inspector shows it as non-bold and sourced from the file — that is the confirmation the migration actually took effect.

Layering with #include

The structure that works:

Config/
  Base.xcconfig          — everything common
  Debug.xcconfig         — #include "Base.xcconfig"
  Release.xcconfig       — #include "Base.xcconfig"
  Staging.xcconfig       — #include "Release.xcconfig"
// Debug.xcconfig
#include "Base.xcconfig"

SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG
SWIFT_OPTIMIZATION_LEVEL = -Onone
GCC_OPTIMIZATION_LEVEL = 0
ONLY_ACTIVE_ARCH = YES
DEBUG_INFORMATION_FORMAT = dwarf
API_BASE_URL = https:/$()/api-dev.example.com
// Release.xcconfig
#include "Base.xcconfig"

SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_COMPILATION_MODE = wholemodule
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
VALIDATE_PRODUCT = YES
API_BASE_URL = https:/$()/api.example.com

That $() is not a typo. // starts a comment in xcconfig, so a URL would be truncated at https:. $() is an empty variable substitution that breaks up the // without appearing in the output. It is the single most confusing thing about the format and it costs everyone an hour once.

Reaching values from code

Add to Info.plist:

<key>APIBaseURL</key>
<string>$(API_BASE_URL)</string>

Then read it:

enum Configuration {
    static let apiBaseURL: URL = {
        guard let string = Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String,
              let url = URL(string: string) else {
            fatalError("APIBaseURL missing from Info.plist")
        }
        return url
    }()
}

fatalError is correct here — a missing build configuration is a build problem, and failing at launch in development beats a nil URL silently disabling networking in production.

Per-configuration app identity

This is the practical payoff people notice first — installing Debug, Staging and Release side by side on one device:

// Debug.xcconfig
PRODUCT_BUNDLE_IDENTIFIER = com.example.app.debug
PRODUCT_NAME = App (Debug)
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-Debug

Different bundle identifier, different name, different icon. Testers stop asking which build they have installed.

What xcconfig cannot do

Two real limitations worth knowing before committing to the migration.

No conditionals beyond the built-in modifiers. You can do SETTING[sdk=iphonesimulator*] = value and [arch=arm64], but there is no if. Anything genuinely conditional needs a build phase script.

It does not cover everything in the project file. Build phases, target dependencies, scheme settings and capabilities stay in project.pbxproj. xcconfig reduces the conflict surface; it does not eliminate it.

Keeping secrets out

An xcconfig is committed, so an API key does not belong in one. The pattern:

// Base.xcconfig
#include? "Secrets.xcconfig"

#include? — with the question mark — does not fail if the file is absent. Secrets.xcconfig goes in .gitignore, with a committed Secrets.xcconfig.template showing which keys are needed. CI provides its own from environment variables.

Why it is worth the afternoon

The build settings review problem is real. A change to project.pbxproj in a pull request is effectively unreviewable — the diff is machine-generated XML, and I have twice approved changes that turned out to have altered a signing setting or a deployment target without anyone noticing.

An xcconfig diff reads like a text file, because it is one:

-IPHONEOS_DEPLOYMENT_TARGET = 17.0
+IPHONEOS_DEPLOYMENT_TARGET = 18.0

That is a change anyone can see and question. That, more than the merge conflicts, is the argument.