The problem
An ECR login token is valid for 12 hours. That is the entire
lifespan of the credential you get from aws ecr get-login-password,
and it is the thing that makes ECR awkward inside Kubernetes.
The trap is that a hand-made pull secret looks like it works. You run
kubectl create secret docker-registry, your Pods pull their images,
and everything is fine — until the next day, when the token has expired and
new Pods sit in ImagePullBackOff with a 403 from the registry. Existing
Pods keep running, which makes the failure worse: the first sign of trouble is a
deploy or a node reboot, long after the change that caused it.
The approach
Treat the pull secret as a rotating credential rather than a static one. Run a CronJob inside the cluster that fetches a fresh token every 8 hours and rewrites the Secret. The 8-hour interval leaves plenty of margin inside the 12-hour window, so there is no moment where the stored token is close to expiring.
The trade-off is a credential with ECR read access living in the cluster, plus RBAC to let one ServiceAccount manage Secrets in its own namespace. Here is the flow:
CronJob (every 8h)
|- read AWS keys from Secret "ecr-secret"
|- aws ecr get-login-password
|- kubectl delete secret ecr-registry-secret --ignore-not-found
'- kubectl create secret docker-registry ecr-registry-secret
Deployment
'- imagePullSecrets: [ecr-registry-secret]
Prerequisites: the AWS credentials Secret
Store the IAM keys in the namespace where the workloads live. Create a dedicated
IAM user for this and attach only the managed policy
AmazonEC2ContainerRegistryReadOnly. A key with broader permissions
turns any cluster compromise into an AWS compromise.
kubectl create secret generic ecr-secret \
--from-literal=access_key='<AWS_ACCESS_KEY_ID>' \
--from-literal=secret_key='<AWS_SECRET_ACCESS_KEY>' \
-n passtobridge
Use placeholders and inject the real values from your own pipeline or a password manager. Do not paste live keys into a manifest, a shell history, or a blog post.
The CronJob
Save this as ecr-cronjob.yaml. It creates the ServiceAccount, the RBAC
to let that account manage Secrets in one namespace, and the CronJob itself.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ecr-updater
namespace: passtobridge
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ecr-updater-role
namespace: passtobridge
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "delete", "create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ecr-updater-rolebinding
namespace: passtobridge
subjects:
- kind: ServiceAccount
name: ecr-updater
namespace: passtobridge
roleRef:
kind: Role
name: ecr-updater-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: ecr-token-refresher
namespace: passtobridge
spec:
# every 8 hours (00:00, 08:00, 16:00)
schedule: "0 */8 * * *"
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
serviceAccountName: ecr-updater
containers:
- name: refresher
image: amazon/aws-cli:latest
env:
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: ecr-secret
key: access_key
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: ecr-secret
key: secret_key
- name: AWS_DEFAULT_REGION
value: "ap-east-1"
command:
- /bin/sh
- -c
- |-
set -e
KUBECTL=/usr/local/bin/kubectl
echo "--- 1. verify the mounted kubectl ---"
$KUBECTL version --client
echo "--- 2. fetch an ECR login token ---"
TOKEN=$(aws ecr get-login-password --region ${AWS_DEFAULT_REGION})
echo "--- 3. recreate the Secret ---"
# delete then create; --ignore-not-found keeps the first run clean
$KUBECTL delete secret ecr-registry-secret -n passtobridge --ignore-not-found
$KUBECTL create secret docker-registry ecr-registry-secret \
--docker-server=839823279171.dkr.ecr.ap-east-1.amazonaws.com \
--docker-username=AWS \
--docker-password="${TOKEN}" \
-n passtobridge
echo "--- done: ECR credentials refreshed ---"
volumeMounts:
- name: host-kubectl
mountPath: /usr/local/bin/kubectl
readOnly: true
restartPolicy: OnFailure
volumes:
- name: host-kubectl
hostPath:
# RKE2's default kubectl path
path: /var/lib/rancher/rke2/bin/kubectl
type: File
Why mount kubectl from the host?
amazon/aws-cli ships the AWS CLI but not kubectl. Rather than build a
custom image, the CronJob mounts the host binary through a hostPath
volume. Two consequences are worth knowing: the CronJob is pinned to whichever node
has that file, and type: File matters — if the path is missing,
the Pod fails loudly at mount time instead of silently running an empty directory.
The alternative is to bake both binaries into one image. It is more work up front but removes the node affinity and works the same on any distribution, not just RKE2.
Why delete then create, instead of patching?
kubectl create secret docker-registry writes the credential into the
.dockerconfigjson key in one shot. Updating that key in place is
possible but fiddly — the value is a nested JSON document, so a patch has to
re-encode the whole thing. Deleting and recreating is idempotent and obvious.
The one thing to keep in mind is the brief window between the delete and the create where the Secret does not exist. Any Pod scheduled in that window has no pull credential. In practice the gap is milliseconds and Pods retry, but if you want to eliminate it entirely, generate the new token first, write it under a temporary name, and swap the reference.
The workload manifest
Every Deployment that pulls from ECR needs to reference the refreshed Secret. Note
that imagePullSecrets does not merge — if you list it here, keep
it here and do not rely on the ServiceAccount's own pull secrets.
spec:
template:
spec:
# 1. reference the rotating credential
imagePullSecrets:
- name: ecr-registry-secret
# 2. schedule onto worker nodes, away from the masters
nodeSelector:
ingress-target: "true"
containers:
- name: my-app
image: 839823279171.dkr.ecr.ap-east-1.amazonaws.com/<repository>:<tag>
Verify it works
Do not wait a day to find out whether the rotation actually runs. Trigger the CronJob manually and check the Secret's age:
# run the job once, right now
kubectl create job --from=cronjob/ecr-token-refresher ecr-manual-test -n passtobridge
# watch it finish
kubectl get pods -n passtobridge -l job-name=ecr-manual-test -w
kubectl logs -n passtobridge -l job-name=ecr-manual-test
# confirm the Secret was rewritten
kubectl get secret ecr-registry-secret -n passtobridge \
-o jsonpath='{.metadata.creationTimestamp}{"\n"}'
The creationTimestamp should be seconds ago, not the day you first
created it. That timestamp is the real proof the rotation path works end to end.
To confirm the previous token would indeed have failed, an easy check is to compare the decoded credential before and after:
kubectl get secret ecr-registry-secret -n passtobridge \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | head -c 120
Set a calendar reminder for the day after deployment and confirm that a Pod created then still pulls successfully. That catches the case where the CronJob runs but writes a credential the kubelet rejects.
Things that bit me
- The 12-hour limit is on the token, not the IAM key. The keys stay valid indefinitely; only the derived login token expires. That is exactly why rotating the Secret works and rotating the IAM user is unnecessary.
-
Region must match the registry. A token from one region cannot
authenticate against another region's registry. Both
AWS_DEFAULT_REGIONand--docker-serverhere sayap-east-1; keep them in sync. - Existing Pods do not re-pull. A stale Secret only surfaces for new Pods, so the failure appears during deploys, scaling, or node reboots. Expect the first symptom to be a deploy that will not roll out.
-
The CronJob needs the Secret to already exist. The
secretKeyRefentries are required at container start, soecr-secretmust be created before the first run, otherwise the Pod fails to start with aCreateContainerConfigError. - Keep the IAM policy minimal. ECR read-only is all this needs. A key with write or broad S3 access widens the blast radius for no benefit.