Separate Debug and Release Bundle IDs in Xcode

Use xcconfig files to give debug builds separate bundle IDs and App Groups, fully isolating dev and production data on the same device.

The Problem

When you run your app from Xcode on a device that also has a TestFlight or App Store build installed, both builds share the same bundle identifier. On iOS and macOS, the bundle ID is the key the OS uses to map an app to its associated resources - including the App Group container, the Keychain, and background URL session identifiers. Shared resources mean shared data: a debug run that clears stored credentials or corrupts the upload queue will affect the production build, and vice versa.

The fix is to give Debug builds a different bundle identifier - typically by appending .debug - so the OS treats them as completely separate apps with no shared state.

Approach: xcconfig Files with a Suffix Variable

The cleanest pattern uses xcconfig files at the project level to define a BUNDLE_ID_SUFFIX variable. That variable is empty in Release and set to .debug in Debug. Every target's PRODUCT_BUNDLE_IDENTIFIER references the suffix, so all four targets update automatically.

Create two files in a Config/ folder at the repo root:

Config/Debug.xcconfig

BUNDLE_ID_SUFFIX = .debug
APP_GROUP_ID = group.com.example.MyApp.debug

Config/Release.xcconfig

BUNDLE_ID_SUFFIX =
APP_GROUP_ID = group.com.example.MyApp

Note the empty value on BUNDLE_ID_SUFFIX in Release - xcconfig syntax for an empty string is just nothing after the equals sign.

Wiring the xcconfig to the Project

The xcconfig files need to be assigned to the project-level build configurations (not per-target). In Xcode:

  1. Open the project navigator and add the Config/ folder to the project.
  2. Select the project in the navigator (not a target), then go to Info.
  3. Under Configurations, set the Debug configuration's "Based on Configuration File" to Config/Debug.xcconfig and Release to Config/Release.xcconfig.

This makes BUNDLE_ID_SUFFIX and APP_GROUP_ID available to all targets, including extensions and helper apps.

Important: If a target's build settings already have a hardcoded PRODUCT_BUNDLE_IDENTIFIER value, it will win over the xcconfig. You have two options:

  • Suffix approach (less invasive): Leave the existing bundle ID in the target build settings but append $(BUNDLE_ID_SUFFIX) to it: com.example.MyApp$(BUNDLE_ID_SUFFIX). The suffix variable resolves from the xcconfig.
  • Full xcconfig approach: Remove the PRODUCT_BUNDLE_IDENTIFIER from the target build settings entirely and define it only in the xcconfig. Cleaner but requires editing the pbxproj.

Updating Entitlements

App Group identifiers and Keychain access groups are defined in each target's .entitlements file. Replace the hardcoded strings with build variable references:

<!-- Before -->
<key>com.apple.security.application-groups</key>
<array>
  <string>group.com.example.MyApp</string>
</array>
<key>keychain-access-groups</key>
<array>
  <string>$(AppIdentifierPrefix)com.example.MyApp</string>
</array>

<!-- After -->
<key>com.apple.security.application-groups</key>
<array>
  <string>$(APP_GROUP_ID)</string>
</array>
<key>keychain-access-groups</key>
<array>
  <string>$(AppIdentifierPrefix)com.example.MyApp$(BUNDLE_ID_SUFFIX)</string>
</array>

Xcode expands build variables in entitlements files at code-signing time. Automatic signing will not overwrite the variable references in your source .entitlements file.

Making the App Group ID Available at Runtime

Any Swift code that uses the App Group identifier as a runtime string - for UserDefaults(suiteName:), FileManager.containerURL(forSecurityApplicationGroupIdentifier:), or background URL session configuration - needs to read the correct value for the active configuration. The build variable is not automatically available in Swift code.

The solution is to flow the variable through the Info.plist:

  1. Add this key to every target's Info.plist:

     <key>AppGroupID</key>
     <string>$(APP_GROUP_ID)</string>
    
  2. Read it at runtime in Swift:

     let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String
         ?? "group.com.example.MyApp"
    

The fallback to the production group is a safety net if the key is ever missing - it ensures the app doesn't silently lose access to its data.

For apps with extension targets (share extensions, app clips, widgets), each target has its own Info.plist and each needs the AppGroupID key. Extension targets read Bundle.main as their own bundle, not the host app's bundle, so the key must be present in each Info.plist independently.

Identifiers Derived from the App Group

If any other inter-process string identifiers are derived from the App Group - background URL session identifiers, Darwin notification names - derive them at runtime rather than hardcoding:

enum AppGroup {
    static let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String
        ?? "group.com.example.MyApp"
    static var backgroundSessionIdentifier: String { "\(identifier).background-session" }
    static var darwinNotificationName: String { "\(identifier).queue-changed" }
}

This ensures debug and release builds use fully separate session namespaces with no cross-contamination.

Registering the Debug Identifiers in the Developer Portal

Automatic signing can create new provisioning profiles for new bundle IDs, but it cannot create a new App Group - that must be registered manually first.

  1. Go to developer.apple.com under Identifiers > App Groups.
  2. Register group.com.example.MyApp.debug.
  3. Open the project in Xcode. Automatic signing will detect the new bundle IDs and create matching profiles for each target automatically.

For command-line builds, pass -allowProvisioningUpdates to xcodebuild so it can fetch the new profiles:

xcodebuild -scheme MyApp -destination "platform=macOS" -allowProvisioningUpdates build

What This Gives You

After this setup:

  • Debug and Release builds coexist on the same device as completely separate apps.
  • The upload queue, stored auth tokens, license keys, and any other App Group data are fully isolated.
  • Switching between configurations in Xcode requires no manual cleanup of shared state.
  • The production build's data is never touched during development.