2.3 — Build settings, configurations, and xcconfig files
Opening scenario
The product manager messages: “Can you point the staging build at the new API and add a ‘STAGING’ watermark in the corner?” — for the second time this month. The first time, you forked the source and added #if STAGING everywhere. The PR was 200 lines, the merge conflicts were brutal, and your tech lead said “do it the right way next time.”
This is the next time. The right way is:
struct AppEnvironment {
let apiBaseURL: URL
let watermark: String?
static let current: AppEnvironment = .fromInfoPlist()
}
…with the values populated from Info.plist, which is populated from .xcconfig files, which are selected by build configuration, which is selected by scheme. Three layers of indirection, but each is justified, and the result is that adding a new environment is a 5-minute task with no source-code changes.
The hierarchy of settings
xcodebuild command-line override
↓
Target-level setting
↓
Project-level setting
↓
.xcconfig file
↓
Default / inherited value
Higher in this list wins. The trick to staying sane: set as little as possible at the top, push defaults into .xcconfig at the bottom, and let the build settings UI act as an inspector, not a primary editor.
Concept → Why → How → Code
Build settings — what they are
Open any target → Build Settings tab. You’ll see a few hundred keyed settings: SWIFT_VERSION = 6.0, IPHONEOS_DEPLOYMENT_TARGET = 17.0, MARKETING_VERSION = 1.0.0, PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp. Each setting can be:
- A simple string / number
- A list (space-separated, sometimes quoted)
- A reference to another setting using
$(OTHER_SETTING)or${OTHER_SETTING}
The $(inherited) token is special — it means “the value coming from the level below this one.” You’ll use it constantly when appending flags without overriding:
OTHER_SWIFT_FLAGS = $(inherited) -warnings-as-errors
Build configurations — Debug, Release, and whatever else you need
A project starts with two configurations: Debug and Release. They differ in:
- Optimization (
SWIFT_OPTIMIZATION_LEVEL = -Ononevs-O) - Debug symbols (
DEBUG_INFORMATION_FORMAT = dwarfvsdwarf-with-dsym) - Whether
-DDEBUGis defined (so#if DEBUGworks)
You can add more (Project → Info → Configurations → +). A common production setup:
| Configuration | When used |
|---|---|
Debug | Day-to-day development |
Debug-Staging | Local builds pointing at staging |
Release-Staging | TestFlight builds for QA |
Release | App Store distribution |
xcconfig files — externalizing build settings
A .xcconfig file is a flat key-value file:
// Shared.xcconfig
SWIFT_VERSION = 6.0
IPHONEOS_DEPLOYMENT_TARGET = 17.0
MARKETING_VERSION = 1.0.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
// Debug.xcconfig
#include "Shared.xcconfig"
SWIFT_OPTIMIZATION_LEVEL = -Onone
API_BASE_URL = https:/$()/api.dev.example.com
// Release.xcconfig
#include "Shared.xcconfig"
SWIFT_OPTIMIZATION_LEVEL = -O
API_BASE_URL = https:/$()/api.example.com
Two quirks:
//starts a comment, even inside URLs. That’s why you’ll seehttps:/$()/api.example.com— the empty$()interpolation breaks//from being read as a comment. It’s an ancient and absurd workaround that every iOS engineer types eventually.#includeis supported (notimport) — relative paths from the including file.
To wire it up: Project → Info → Configurations → expand a configuration → set the xcconfig file for the project (or each target). Now the configuration’s defaults come from the file.
Bridging build settings into Swift code via Info.plist
Build settings are compile-time. To read them at runtime, you stage them through Info.plist and then read the bundle’s info dictionary. The Info Plist gets processed during build, so $(API_BASE_URL) in an Info.plist value gets substituted:
<!-- Info.plist -->
<key>APIBaseURL</key>
<string>$(API_BASE_URL)</string>
enum AppEnvironment {
static let apiBaseURL: URL = {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String,
let url = URL(string: raw) else {
fatalError("APIBaseURL missing or invalid in Info.plist")
}
return url
}()
}
Now changing API_BASE_URL in Release-Staging.xcconfig automatically routes the staging TestFlight build to the staging API. No source code change. No #if STAGING anywhere.
Compile-time flags: #if DEBUG, #if STAGING
For behavior that needs to compile in or out, use Swift Active Compilation Conditions (Build Settings → Swift Compiler — Custom Flags → Active Compilation Conditions). Add STAGING to the staging configurations:
#if STAGING
private let watermarkText: String? = "STAGING"
#else
private let watermarkText: String? = nil
#endif
These are not the same as OTHER_SWIFT_FLAGS = -D STAGING (which works too but is more verbose). Use Active Compilation Conditions for cleanliness.
Build phases — what happens, in order
Each target has a list of Build Phases (the “Build Phases” tab). The default for an iOS app:
- Target Dependencies — build these first
- Compile Sources —
.swiftand.mfiles - Link Binary with Libraries — link frameworks
- Copy Bundle Resources — assets, plists, storyboards
You can add custom build phases:
- Run Script Phase —
swiftlint,swiftformat, sentry-cli upload of dSYMs, custom code-gen.
Run Script phases run on every build by default. Add
${SRCROOT}/Path/To/inputsand${SRCROOT}/Path/To/outputsto make Xcode skip the phase when nothing’s changed. Without this, every incremental build will run your script and your build times will rot.
In the wild
- Most professional iOS projects keep one
.xcconfigper configuration in aConfig/folder at the repo root. Some even check the API URLs in plain text — they’re not secrets, they’re routing. - Apple’s CoreFoundation header is full of
OTHER_CFLAGSthat get inherited via.xcconfig. - Fastlane (the CD toolchain used at Lyft, Twitter, Snapchat) reads
MARKETING_VERSIONandCURRENT_PROJECT_VERSIONdirectly from the xcconfig to compute the next App Store version. - CocoaPods generates
.xcconfigfiles (Pods/Target Support Files/Pods-MyApp/Pods-MyApp.debug.xcconfig) — this is the actual mechanism by which CocoaPods plumbs framework paths into your project.
Common misconceptions
-
“I’ll just use
#if DEBUGfor everything.” Works for tiny apps, breaks at scale. The#ifblocks cluster in random files, you forget to update some, and the bundle ID / API URL still gets baked at compile time. xcconfig-driven configuration centralizes the difference. -
“
Info.plistis just metadata.” It’s a runtime-readable processed key/value store. Use it as the bridge between build settings and Swift code. -
“You should never edit
.pbxprojdirectly.” Mostly true — but knowing how to read it is critical for resolving merge conflicts. Open it in a text editor occasionally and learn the structure; one day you’ll thank yourself. -
“Setting
DEBUG = 1makes#if DEBUGwork.” Subtly wrong.DEBUG = 1adds the C preprocessor define; Active Compilation Conditions drive Swift’s#if. TheDebugconfiguration ships with both pre-set; that’s why it works. -
“All my Run Script phases should run every build.” No. Define inputs and outputs and Xcode will skip them when nothing’s changed. A 12-second script that runs on every keystroke compounds painfully.
Seasoned engineer’s take
The mental shift that turns a junior into a confident Xcode user: stop editing build settings in the GUI. Move them into .xcconfig. The GUI is unreviewed config drift; the .xcconfig is config-as-code that diff-reviews cleanly.
A pattern I deploy on every new project:
Config/
├── Shared.xcconfig # all configurations inherit
├── Debug.xcconfig # dev simulator
├── Debug-Staging.xcconfig # locally pointed at staging
├── Release-Staging.xcconfig# TestFlight
└── Release.xcconfig # App Store
Shared.xcconfig carries SWIFT_VERSION, deployment target, bundle ID prefix, Swift strict-concurrency settings. The per-config files carry only the differences. Pull requests that bump a deployment target show a clear single-line diff.
For secrets — API keys, OAuth client IDs — do not check them into xcconfig. They’re in source control. Use xcconfig only for non-secret routing (URLs, bundle IDs, feature flags). Real secrets belong in the Keychain at runtime, or in environment variables injected at CI build time.
TIP: Search through Apple’s open-source repos (e.g.,
swift-package-manager) for.xcconfigexamples — they’re a masterclass in setting hygiene. Look at how settings are grouped, commented, and inherited.
WARNING: Never check secrets into
.xcconfig— they’re plain-text in your repo. API keys, Firebase config, Stripe publishable keys (yes, even publishable) all leak into git history and trigger GitHub secret-scanning alerts that look bad in interviews.
Interview corner
Question: “How would you set up an iOS project to support Dev, Staging, and Prod environments?”
Junior answer: “I’d add #if DEV, #if STAGING, #if PROD and define a constant in each branch.” → Works for a coffee-shop side project. They’ll keep digging.
Mid-level answer: “I’d add three build configurations — Debug, Release-Staging, Release — backed by three .xcconfig files. The xcconfigs define API_BASE_URL, BUNDLE_ID_SUFFIX, and MARKETING_VERSION per environment. Those settings get plumbed into Info.plist using $(VAR) substitution, and Swift reads them at runtime via Bundle.main.object(forInfoDictionaryKey:). I’d then create three schemes — one per environment — each selecting the right configuration for Run, Test, Archive, and Profile actions.” → Strong, complete, what an interviewer wants.
Senior answer: Plus: “I’d also separate the bundle ID per environment (com.example.MyApp.dev, com.example.MyApp.staging, com.example.MyApp) so all three can install side-by-side on a device — invaluable during QA. I’d use Active Compilation Conditions for anything truly compile-time (like swapping in a mock network layer for Debug builds). I’d handle secrets out-of-band: not in xcconfig, but injected into the build via xcrun agvtool or fetched from a CI secret store. And I’d document the matrix — which scheme builds with which configuration and points at which backend — in a README block, because three environments × four actions = twelve combinations that a new hire will mis-remember on day three.” → Senior signal: thinks about side-by-side installs, secret hygiene, documentation.
Red-flag answer: “I’d ship a Settings.bundle toggle that lets the user pick the backend.” → Tells the interviewer the candidate is going to ship a debug UI to App Store reviewers.
Lab preview
In Lab 2.1 you’ll create the Debug / Release-Staging / Release configurations + matching xcconfig files for a real starter app and wire them into a Configuration.swift runtime accessor.
Next: the dozen Xcode shortcuts and refactor tools that separate “Xcode user” from “Xcode driver.” → Tips, tricks & shortcuts