Modern Swift services increasingly run alongside the same cloud native infrastructure stacks that power much of today’s Kubernetes ecosystem — including ConfigMaps, containerized workloads, declarative deployments, and service lifecycle management. Projects such as Prometheus and OpenTelemetry have helped standardize observability and operational patterns across distributed systems, but configuration management in Swift services has remained comparatively ad hoc.

Swift is actively used to build production services on Linux, benefiting from modern concurrency, memory and data race safety guarantees, and strong performance characteristics. In practice, however, configuration is often assembled manually by reading environment variables with ProcessInfo.environment and parsing files directly using YAML, JSON, or similar formats.

These approaches work for simple cases, but they leave several operational concerns unresolved:

Swift Configuration was built to address these gaps. It provides a layered provider model with explicit precedence rules, file-based hot reloading designed for Kubernetes-style ConfigMap volumes, and immutable configuration snapshots that guarantee readers observe a consistent view of configuration during runtime updates.

This post walks through those patterns using a complete Kubernetes service as an example.

Reading configuration: readers, providers, and hierarchy

The Swift Configuration library separates reading configuration from providing it. A ConfigReader takes an ordered list of types that conform to ConfigProvider. The first provider that has a value for a given key takes precedence. You explicitly compose the priority chain.

In production, it’s common to stack providers with the highest priority first:

// Providers are initialized asynchronously: EnvironmentVariablesProvider reads
// the process environment and .env file at initialization, so the initialization is asynchronous.
async let staticProviders: [(any ConfigProvider)] = [
   CommandLineArgumentsProvider(),
   EnvironmentVariablesProvider(),
   EnvironmentVariablesProvider(environmentFilePath: ".env", allowMissing: true),
   InMemoryProvider(values: [
       "log.level": "info",
       "config.filePath": "/etc/config/appsettings.yaml",
       "config.pollIntervalSeconds": 15,
       "http.serverName": "my-service",
   ]),
]

In the above example, CLI arguments override environment variables, which in turn override a .env file, with in-memory defaults as the fallback. The priority order is explicit in the ordering of the providers. There’s no implicit behavior, and adding or reordering sources is a one-line change.

Collect and pass one or more providers to a ConfigReader, which you use to access values:

let initConfig = ConfigReader(providers: staticProviders)

You read values from the configuration provider:

let logLevel = initConfig.string(
    forKey: "log.level",
    as: Logger.Level.self,
    default: .info
)

Keys in Swift Configuration use dot notation to express hierarchy. Scoped readers let a component read from a subtree of the configuration without needing the full key path:


let httpConfig = initConfig.scoped(to: "http") // reads "http.port", "http.host" as just "port", "host"

For providers that don’t support dot syntax natively, such as environment variables and .env files, dot-notation keys are translated automatically. The log.level key, for example, maps to the environment variable LOG_LEVEL. The same key name works across all provider types without any manual mapping.

Hot reloading from a ConfigMap

Static providers handle bootstrap configuration values that don’t change at runtime, but some values need to change while the service is running. For values that must update without a restart — feature flags, rate limits, connection pool sizes — use a dynamic provider.

ReloadingFileProvider is a built-in provider that watches a file for changes and provides consistent snapshots each time the file updates. In Kubernetes, mount a ConfigMap as a volume and point the provider at the mounted path. Kubernetes handles the file update; the ReloadingFileProvider handles the reload.

Swift Configuration ships with YAML and JSON providers. Anyone can conform to ConfigProvider to add new formats — for example, the community has already built a TOML reader. The following example sets up a YAML-based reloading provider that reads the file path from the static configuration:

let reloadingProvider = try await ReloadingFileProvider<YAMLSnapshot>(
   config: initConfig.scoped(to: "config")
)

With the provider scoped to the key config, it reads config.filePath and config.pollIntervalSeconds from the initial ConfigReader.

Assemble a combined configuration reader that stacks the dynamic provider at the top, for use throughout your service:

let config = ConfigReader(
   providers: [reloadingProvider] + staticProviders
)

On the Kubernetes side, configuration lives in a ConfigMap. The structure mirrors what your code expects — nested YAML keys correspond to the dot-notation keys you pass to ConfigReader:

---
apiVersion: v1
kind: ConfigMap
metadata:
name: my-service-config
data:
appsettings.yaml: |
   app:
     name: "production"

The Deployment manifest wires the ConfigMap volume into the container and sets the file path the provider will watch — the full manifest is in the repository example:

containers:
- name: http-server
image: reloading-example:latest
env:
- name: LOG_LEVEL
value: "debug"
# location of the configuration file mounted from ConfigMap
- name: CONFIG_FILE_PATH
value: "/etc/config/appsettings.yaml"
volumeMounts:
- name: config-volume


When you run kubectl apply with an updated ConfigMap, Kubernetes propagates the change to the mounted volume. This typically takes up to a minute or two, driven by the kubelet sync period and the cluster’s ConfigMap cache time-to-live. The poll interval controls how quickly the provider detects a file change, but it doesn’t reduce the Kubernetes propagation window. A 15-second poll interval is the default: low enough to pick up the change promptly after the file lands without placing unnecessary load on the filesystem.

Watching specific values

For cases where your service needs to react the moment a configuration value changes, rather than just reading the new value on the next request, ConfigReader exposes a watch API backed by Swift’s async/await.

The following example is a simple long-running task that conforms to the Service protocol from the Swift Service Lifecycle library. It uses the watch API from ConfigReader to log updates when the file changes.


struct ConfigWatchReporter: Service {
   let config: ConfigReader
   let logger: Logger
   func run() async throws {
       try await self.config.scoped(to: "app")
       .watchString(forKey: "name", default: "unset") { updates in
           for try await update in updates.cancelOnGracefulShutdown() {
               logger.info("Received a configuration change: \(update)")
           }
       }
   }
}

The watch API provides updates as an asynchronous sequence of values; cancelOnGracefulShutdown() ensures the service shuts down cleanly. Register the reporter alongside the ReloadingFileProvider when you assemble the app:

let configReporter = ConfigWatchReporter(config: config, logger: logger)
let app = Application(
   router: router,
   configuration: ApplicationConfiguration(reader: config.scoped(to: "http")),
   services: [
       reloadingProvider,
       configReporter,
   ],
   logger: logger
)


Consistent snapshots and torn reads

Hot reloading, whether polled by ReloadingFileProvider or observed through the watch API, introduces a subtlety worth addressing directly: if two reads in the same request observe different configuration versions, you have a torn read. Imagine a middleware that reads a rate limit ceiling from config, and the handler that runs after it reads the same key and sees a different value because the file reloaded in between. Torn reads are nondeterministic and reliably difficult to reproduce in testing.

Swift Configuration prevents this through snapshots. A configuration snapshot is an immutable capture of the configuration state at a point in time. Atomicity is a protocol-level guarantee. Every provider must deliver it, not just ReloadingFileProvider. When a reload fires, the provider replaces the snapshot reference in a single atomic assignment — the entire key-value map at once, not entry by entry. No reader can observe a partial update.

Snapshots also guard against a related risk: malformed reloads. If a reload produces an invalid or unparseable result, ReloadingFileProvider retains the last known good snapshot rather than replacing it. Your service continues running on the previous valid configuration, and the provider logs the failed reload so you can investigate without an outage.

If your operation spans multiple reads and needs a guaranteed consistent view across all of them, capture a snapshot directly from the provider API.

Putting it together: the Hummingbird integration

With each individual piece established, here’s how they assemble into a working Kubernetes service using Hummingbird.

A full working example from this post, including the Kubernetes manifests, is available in the reloading-example directory of the repository.

The application is assembled first from a static configuration. This static configuration bootstraps a reloading provider, and then all the providers are combined to create a configuration reader for the Hummingbird application.

func buildApplication(initialConfigProviders: [(any ConfigProvider)]) async throws
   -> some ApplicationProtocol
{

   // Create an initial configuration reader to bootstrap readers
// that depend on it, such as a ReloadingFileProvider, and
// setting up logging.
let initConfig = ConfigReader(providers: initialConfigProviders)

   let logger = {
       var logger = Logger(label: initConfig.string(
           forKey: "http.serverName",
           default: "default-HB-server"
       ))
       logger.logLevel = initConfig.string(
           forKey: "log.level",
           as: Logger.Level.self,
           default: .info)
       return logger
   }()

   // Create a dynamic configuration provider that watches a file
// for changes. When the file changes, it reloads. The file path and
// polling interval are read from the initial configuration reader.
let reloadingProvider = try await ReloadingFileProvider<YAMLSnapshot>(
       config: initConfig.scoped(to: "config")
   )
   // Assemble a final configuration reader that includes
// the dynamic provider
let config = ConfigReader(
       providers: [reloadingProvider] + initialConfigProviders,
       accessReporter: AccessLogger(logger: logger)
   )

   let configReporter = ConfigWatchReporter(
       config: config,
       logger: logger)

   // Assemble the routes for the app.
let router = try buildRouter(config: config)

   // Create the hummingbird app and add the services to it.
// This runs a background service that watches for filesystem
// changes for configuration, and another that reports changes
// to a specific configuration value.
let app = Application(
       router: router,
       configuration: ApplicationConfiguration(reader: config.scoped(to: "http")),
       services: [
           reloadingProvider,
           configReporter,
       ],
       logger: logger
   )
   return app
}

The two-phase pattern of a bootstrap ConfigReader followed by the full reader exists because  ReloadingFileProvider needs to know the file path and poll interval before it can start. Reading those values from the initial static providers, then layering the dynamic provider on top, avoids a circular dependency.

The full ConfigReader uses an AccessLogger tied to the application logger. The AccessLogger records each configuration value access and ensures secrets are not logged in plain text.

Getting started

To add Swift Configuration to your own package, make sure to select the traits for the features you want to enable when you add the dependency to your project’s Package.swift.

The following example shows activating the traits for reloading, YAML, and command line arguments in addition to the default providers:

.package(
   url: "https://github.com/apple/swift-configuration.git",
   from: "1.0.0",
   traits: [.defaults, "CommandLineArguments", "Reloading", "YAML"]
)

Get involved

Documentation for Swift Configuration is on the Swift Package Index, a video walkthrough is available on YouTube, and a post on the Swift Blog introduces its capabilities.

Swift Configuration is at 1.0 and ready for production use. Open an issue if you hit a problem, and check the CONTRIBUTING guide to submit a pull request. The provider protocol is open — if your stack needs a format or source that doesn’t exist yet, you can build it. We’d love you to tell us how this works for your scenario, and if there’s anything missing that you need to use in production services written in Swift.