A ConfigMap stores non-confidential configuration separately from an image. That lets a release change an environment-specific value without rebuilding the application artifact. The useful boundary is explicit: the image supplies code, while the workload definition identifies which configuration that code should consume. A release is therefore more than an image tag; its configuration reference is part of the behavior being deployed.

Give configuration a reviewable identity

ConfigMap data values are strings, with binaryData available for binary values represented through the API’s encoding. The object is intended for small configuration, with a one-MiB data limit. It is not a general file repository or a secret-protection mechanism. The API can validate object structure without understanding whether a particular application setting is meaningful or safe for the business.

A Pod can consume values through environment references or mounted files. Ordinary Pod references resolve the ConfigMap in that Pod’s namespace. Choose names and required keys deliberately, then validate the application’s interpretation. An accepted Deployment object does not prove that every referenced key exists or that a new container can start successfully with the selected values.

Define units, accepted values, and defaults in the application’s configuration schema. A stored string resembling a boolean or number does not choose its parsing rules. Review those rules with the consumer before promoting a configuration between environments.

Versioned immutable ConfigMaps make release identity easier to inspect. A name such as settings-v2 can be retained alongside settings-v1, with immutable preventing data changes to the existing object. This protection does not prohibit an authorized actor from deleting and recreating a name. Keep object lifecycle permissions and release records aligned with the intended immutability promise.

When it breaks

Editing configuration and replacing processes are different actions. Environment values already injected into a running container are not rewritten by a ConfigMap update. Normal projected files can update eventually, but the application must read the new contents; subPath and previously opened files introduce additional lifetime distinctions. Do not report a configuration rollout merely because the API now shows a new value.

Changing a Deployment’s Pod-template reference to a new ConfigMap is a concrete rollout trigger. Changing the ConfigMap itself does not automatically rewrite that template. Likewise, a checksum annotation is useful only when a tool actually updates it in the Pod template. Kubernetes does not discover arbitrary external configuration changes and generate that checksum on the application’s behalf.

Rollback depends on retained inputs. Repointing a workload to settings-v1 fails if the old object has been deleted, and restoring an image alone cannot restore a changed configuration. Keep needed versions available and check consumers before cleanup. Configuration changes also need business validation; a syntactically valid threshold can still disable a service or change its behavior incorrectly.

Check it quickly

Use a disposable Kubernetes cluster with kubectl access and python:3.11-slim-bookworm available to its nodes. Save this as config-releases.py and run python3 config-releases.py KUBECONFIG_PATH CONTEXT, substituting that cluster’s dedicated configuration path and context name. The test needs permission to create and delete its own namespace. The recorded server version was Kubernetes 1.34.11, with kubectl 1.34.1.

import json
import subprocess
import sys
import time
from uuid import uuid4

assert len(sys.argv) == 3, 'usage: python3 example.py DISPOSABLE_KUBECONFIG CONTEXT'
base = ['kubectl', '--kubeconfig', sys.argv[1], '--context', sys.argv[2], '--request-timeout=30s']
image = 'python:3.11-slim-bookworm'


def k(*args, payload=None, expected=0):
    result = subprocess.run(base + list(args), input=json.dumps(payload) if payload is not None else None,
                            capture_output=True, text=True, timeout=150)
    if expected is not None:
        assert result.returncode == expected, result.stderr
    return result


def obj(namespace, kind, name=None, selector=None):
    args = ['get', kind, '-n', namespace, '-o', 'json']
    if name:
        args.append(name)
    if selector:
        args += ['-l', selector]
    return json.loads(k(*args).stdout)


def apply(namespace, data):
    return k('apply', '-n', namespace, '-f', '-', payload=data)


def wait_for(read, ready, seconds=120):
    until = time.monotonic() + seconds
    while time.monotonic() < until:
        value = read()
        if ready(value):
            return value
        time.sleep(0.5)
    raise AssertionError('expected Kubernetes condition did not arrive')


def pod_ready(pod):
    return any(c['type'] == 'Ready' and c['status'] == 'True'
               for c in pod.get('status', {}).get('conditions', []))


def spec(script='import time; time.sleep(600)'):
    return {'automountServiceAccountToken': False, 'terminationGracePeriodSeconds': 1,
            'containers': [{'name': 'app', 'image': image, 'imagePullPolicy': 'IfNotPresent',
                            'command': ['python3', '-c', script],
                            'resources': {'requests': {'cpu': '10m', 'memory': '24Mi'},
                                          'limits': {'cpu': '200m', 'memory': '96Mi'}}}]}


def deployment(namespace, name, podspec):
    return apply(namespace, {'apiVersion': 'apps/v1', 'kind': 'Deployment', 'metadata': {'name': name},
        'spec': {'replicas': 1, 'selector': {'matchLabels': {'app': name}},
                 'template': {'metadata': {'labels': {'app': name}}, 'spec': podspec}}})


def deployment_pod(namespace, name):
    wait_for(lambda: obj(namespace, 'deployment', name), lambda d:
             d.get('status', {}).get('observedGeneration', 0) >= d['metadata']['generation']
             and d.get('status', {}).get('updatedReplicas') == 1
             and d.get('status', {}).get('availableReplicas') == 1
             and d.get('status', {}).get('replicas') == 1)
    def pods():
        return [p for p in obj(namespace, 'pods', selector='app=' + name)['items']
                if not p['metadata'].get('deletionTimestamp')]
    return wait_for(pods, lambda p: len(p) == 1 and pod_ready(p[0]))[0]

namespace = 'notes-config-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
    for version, mode in [('v1', 'blue'), ('v2', 'green')]:
        apply(namespace, {'apiVersion': 'v1', 'kind': 'ConfigMap',
              'metadata': {'name': 'settings-' + version}, 'immutable': True, 'data': {'mode': mode}})
    podspec = spec()
    podspec['containers'][0]['env'] = [{'name': 'MODE', 'valueFrom':
        {'configMapKeyRef': {'name': 'settings-v1', 'key': 'mode'}}}]
    deployment(namespace, 'web', podspec)
    first = deployment_pod(namespace, 'web')
    def mode(pod):
        return k('exec', '-n', namespace, pod['metadata']['name'], '--', 'python3', '-c',
                 'import os; print(os.environ["MODE"])').stdout.strip()
    assert mode(first) == 'blue'
    rejected = k('patch', 'configmap', 'settings-v1', '-n', namespace, '--type=merge',
                 '-p', json.dumps({'data': {'mode': 'changed'}}), expected=None)
    assert rejected.returncode != 0 and 'immutable' in rejected.stderr
    assert obj(namespace, 'configmap', 'settings-v1')['data']['mode'] == 'blue'
    def select(version):
        patch = [{'op': 'replace',
                  'path': '/spec/template/spec/containers/0/env/0/valueFrom/configMapKeyRef/name',
                  'value': 'settings-' + version}]
        k('patch', 'deployment', 'web', '-n', namespace, '--type=json', '-p', json.dumps(patch))
        return deployment_pod(namespace, 'web')
    second = select('v2')
    assert mode(second) == 'green' and second['metadata']['uid'] != first['metadata']['uid']
    third = select('v1')
    assert mode(third) == 'blue' and third['metadata']['uid'] != second['metadata']['uid']
    image_ids = [p['status']['containerStatuses'][0]['imageID'] for p in (first, second, third)]
    assert len(set(image_ids)) == 1
    assert len({p['metadata']['uid'] for p in (first, second, third)}) == 3
    print(json.dumps({'modes': ['blue', 'green', 'blue'], 'distinct_pod_uids': 3,
                      'same_image_id': image_ids[0], 'immutable_update_rejected': True,
                      'retained_config_rollback_verified': True}))
finally:
    k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')

The script creates two immutable ConfigMaps and a single-replica Deployment referencing settings-v1. The first Pod reads blue from its environment. Attempting to overwrite that ConfigMap’s data is rejected, and reading the object confirms the original value remains blue. This checks actual server enforcement rather than just the manifest’s immutable field.

Next, a Pod-template patch selects settings-v2. After controller reconciliation and readiness, a different Pod reads green. Selecting the retained first version causes another replacement that reads blue again. The three Pod UIDs are distinct, while all three observed image identities match. All assertions passed, and the temporary namespace was removed.

This verifies configuration selection, replacement, and a retained-input rollback using a simple worker. It does not establish application compatibility, production availability during rollout, or rollback of external side effects. Carry the image, configuration version, workload definition, and observed application result together in the release evidence so each part of the change can be reviewed.

Sources