Skip to content

Commit 067ca25

Browse files
feat(registry): authenticate to private ECR with the AWS credential chain (#40)
Destination registries were authenticated only through docker credential config, so ECR required a docker login that had already been performed in the runtime environment. The GitHub Action cannot satisfy that: it runs as a Docker container action, which does not receive the runner's ~/.docker/config.json, so credentials written by aws-actions/amazon-ecr-login on the host were invisible to it and every request failed with 401 Unauthorized. ECRKeychain recognises private ECR hosts by their registry host, reads the account and region from it, and calls ecr:GetAuthorizationToken through the standard AWS credential chain. Tokens are cached per account and region and refetched inside a five minute window before expiry, so a continuously running sync no longer needs an external refresh loop. An existing docker credential entry for the same registry still wins, which keeps explicit logins working. Public ECR is a separate service with a separate token API and is not covered. The Helm chart gains serviceAccount.annotations so a deployment can attach an IRSA role and drop dockerConfigSecret for ECR entirely. Co-authored-by: Anmol Nagpal <ianmolnagpal@gmail.com>
1 parent ebeb865 commit 067ca25

13 files changed

Lines changed: 593 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Native authentication for private Amazon ECR destinations: when a destination host matches `<account>.dkr.ecr.<region>.amazonaws.com`, SyncerD calls `ecr:GetAuthorizationToken` through the standard AWS credential chain (environment variables, shared config, IRSA, instance role) instead of requiring a `docker login`. Tokens are cached per account and region and refetched before they expire, so a long-running sync no longer needs an external refresh loop, and a Kubernetes deployment using IRSA no longer needs a `dockerConfigSecret` for ECR. An existing docker credential entry for the same registry still wins, so setups that log in explicitly are unchanged. Public ECR (`public.ecr.aws`) is a different service and is not covered
12+
13+
### Fixed
14+
- The GitHub Action could not authenticate to any destination registry that was logged into with `docker/login-action` or `aws-actions/amazon-ecr-login`: it runs as a Docker container action, which does not receive the runner's `~/.docker/config.json`, so every request failed with `401 Unauthorized`. ECR now works with AWS credentials alone, and the README documents the `DOCKER_CONFIG` workspace path that makes the other registries' login steps visible to the container
15+
1016
## [0.1.1] - 2026-08-14
1117

1218
### Added

README.md

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ That's it. Use the same config in [GitHub Actions](#use-as-a-github-action-marke
9494
| **GitHub Action** | [Marketplace action](https://github.com/marketplace) — drop into workflows |
9595
| **Helm chart** | Run as a CronJob on Kubernetes; stateless by default (no PVC) |
9696
| **Slack** | Optional branded Block Kit alerts (color-coded, per-destination grouping) on new syncs and failures (compact/detailed) |
97-
| **Secure** | Docker Hub via env/secret; destinations via Docker credential config |
97+
| **Secure** | Docker Hub via env/secret; destinations via Docker credential config, ECR via the AWS credential chain |
9898
| **Git mirroring** | Mirror repositories across GitHub, GitLab, Bitbucket, Azure DevOps, and AWS CodeCommit (`git-sync`); filtered discovery, safe push modes, dry run |
9999

100100
---
@@ -129,7 +129,40 @@ Add SyncerD to your workflow:
129129
130130
Pin to the latest [release tag](https://github.com/clouddrove/syncerd/releases); a floating `v1` tag will exist once the project reaches a 1.0 release.
131131

132-
Add Docker credential steps (e.g. `docker/login-action`, `aws-actions/amazon-ecr-login`) *before* SyncerD so destination registries are authenticated.
132+
**Destination registry auth in the Action:**
133+
134+
For **AWS ECR**, add `aws-actions/configure-aws-credentials` before SyncerD and nothing else: SyncerD requests an ECR token itself from the AWS credential chain, so `aws-actions/amazon-ecr-login` is not needed.
135+
136+
```yaml
137+
- uses: aws-actions/configure-aws-credentials@v6
138+
with:
139+
role-to-assume: arn:aws:iam::123456789012:role/syncerd
140+
aws-region: eu-west-1
141+
142+
- uses: clouddrove/syncerd@v0.1.1
143+
with:
144+
config: syncerd.yaml
145+
```
146+
147+
For **GHCR, ACR, GCR** (and for ECR if you prefer an explicit `docker login`), the login step has to write its credentials somewhere the Action can read. SyncerD runs as a Docker container action, and a container action does not get the runner's `~/.docker/config.json`, so a plain `docker/login-action` step is invisible to it and every request fails with `401 Unauthorized`. Point `DOCKER_CONFIG` at the workspace, which *is* mounted into the container at `/github/workspace`:
148+
149+
```yaml
150+
env:
151+
DOCKER_CONFIG: ${{ github.workspace }}/.docker # job level: where login steps write
152+
153+
steps:
154+
- uses: docker/login-action@v3
155+
with:
156+
registry: ghcr.io
157+
username: ${{ github.actor }}
158+
password: ${{ secrets.GITHUB_TOKEN }}
159+
160+
- uses: clouddrove/syncerd@v0.1.1
161+
env:
162+
DOCKER_CONFIG: /github/workspace/.docker # same directory, path inside the container
163+
with:
164+
config: syncerd.yaml
165+
```
133166

134167
**Inputs:**
135168

@@ -213,10 +246,11 @@ Set `config.destinations` and `config.images` in `values.yaml` or via `--set`.
213246

214247
**Credentials:**
215248
- Docker Hub (source): use `existingSecret` (recommended) or `secret.*` in values.
216-
- Destination registries (ECR/ACR/GCR/GHCR): create a Docker config secret and set `dockerConfigSecret`. SyncerD automatically sets `DOCKER_CONFIG=/var/lib/syncerd/.docker` so the credentials are found even when the pod runs as a non-root user.
249+
- Destination registries (ACR/GCR/GHCR): create a Docker config secret and set `dockerConfigSecret`. SyncerD automatically sets `DOCKER_CONFIG=/var/lib/syncerd/.docker` so the credentials are found even when the pod runs as a non-root user.
250+
- Destination ECR registries: give the pod AWS credentials instead (IRSA, EKS Pod Identity, or an instance role with `ecr:GetAuthorizationToken` plus push permissions on the target repositories). SyncerD fetches and refreshes the ECR token itself, so no `dockerConfigSecret` and no refresh CronJob is needed.
217251
- To pull the SyncerD image itself from a private registry, use `imagePullSecrets` — this is separate from `dockerConfigSecret`.
218252

219-
**ECR note:** ECR tokens expire every 12 hours. Refresh `dockerConfigSecret` before expiry:
253+
**ECR without IAM:** if the pod cannot be given AWS credentials, a Docker config secret still works, but ECR tokens expire every 12 hours and the secret has to be refreshed before expiry:
220254

221255
```bash
222256
aws ecr get-login-password --region <region> | \
@@ -255,7 +289,7 @@ Full example: [syncerd.yaml.example](syncerd.yaml.example).
255289
| Section | Purpose |
256290
|---------|---------|
257291
| `source` | Docker Hub (username/password or token via env or config) |
258-
| `destinations` | List of registries (ECR, ACR, GCR, GHCR); auth via Docker credential config |
292+
| `destinations` | List of registries (ECR, ACR, GCR, GHCR); auth via Docker credential config, or the AWS credential chain for ECR |
259293
| `images` | Images to sync; optional `tags`, `watch_tags` for new tag detection |
260294
| `schedule` | Cron expression when running without `--once` |
261295
| `state_path` | Optional state file for "already synced" tracking; leave empty for fully stateless |
@@ -273,8 +307,8 @@ Override with `SYNCERD_` prefix:
273307
### Authentication
274308

275309
- **Docker Hub (source):** Username/password or Personal Access Token (env or config). Credentials are validated at startup.
276-
- **Destinations (ECR/ACR/GCR/GHCR):** SyncerD uses the default Docker keychain — `docker login`, credential helpers, or GitHub Actions login steps.
277-
- **ECR:** Tokens expire every 12 hours. Ensure credentials are refreshed before each scheduled sync.
310+
- **Destinations (ACR/GCR/GHCR):** SyncerD uses the default Docker keychain — `docker login`, credential helpers, or GitHub Actions login steps.
311+
- **ECR:** AWS credentials are enough. When a destination host is a private ECR registry (`<account>.dkr.ecr.<region>.amazonaws.com`), SyncerD calls `ecr:GetAuthorizationToken` through the standard AWS credential chain (environment variables, shared config, IRSA, instance role) and refreshes the token as it expires, so long-running syncs do not need a credential refresh loop. The account and region come from the registry host. A `docker login` for the same registry still takes precedence when one exists. Public ECR (`public.ecr.aws`) is not covered by this and needs a `docker login`.
278312

279313
---
280314

_helm/syncerd/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,15 @@ SyncerD uses the Docker credential config for destination registry auth. Create
7575
7676
#### AWS ECR
7777

78-
ECR tokens expire every **12 hours**. Refresh the secret before each sync window.
78+
Give the pod AWS credentials (IRSA, EKS Pod Identity, or a node instance role) and skip `dockerConfigSecret` entirely: SyncerD requests an ECR token itself through the standard AWS credential chain and refreshes it as it expires. The role needs `ecr:GetAuthorizationToken` plus push permissions on the destination repositories.
79+
80+
```yaml
81+
serviceAccount:
82+
annotations:
83+
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/syncerd
84+
```
85+
86+
Without AWS credentials, a Docker config secret still works, but ECR tokens expire every **12 hours**, so the secret has to be refreshed before each sync window.
7987
8088
```bash
8189
# Log in and create/update the secret

_helm/syncerd/templates/serviceaccount.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ metadata:
55
name: {{ include "syncerd.fullname" . }}
66
labels:
77
{{- include "syncerd.labels" . | nindent 4 }}
8+
{{- with .Values.serviceAccount.annotations }}
9+
annotations:
10+
{{- toYaml . | nindent 4 }}
11+
{{- end }}
812
{{- end }}

_helm/syncerd/values.schema.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,11 @@
145145
"additionalProperties": false,
146146
"properties": {
147147
"create": { "type": "boolean" },
148-
"name": { "type": "string" }
148+
"name": { "type": "string" },
149+
"annotations": {
150+
"type": "object",
151+
"additionalProperties": { "type": "string" }
152+
}
149153
},
150154
"required": ["create"]
151155
}

_helm/syncerd/values.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,7 @@ securityContext:
207207
serviceAccount:
208208
create: true
209209
name: ""
210+
# Annotations on the created service account. Use this for IRSA when syncing
211+
# to ECR, e.g.
212+
# eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/syncerd
213+
annotations: {}

go.mod

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.0
55
require (
66
github.com/aws/aws-sdk-go-v2/config v1.32.36
77
github.com/aws/aws-sdk-go-v2/service/codecommit v1.37.0
8+
github.com/aws/aws-sdk-go-v2/service/ecr v1.60.6
89
github.com/google/go-containerregistry v0.19.0
910
github.com/robfig/cron/v3 v3.0.1
1011
github.com/spf13/cobra v1.8.0
@@ -13,19 +14,19 @@ require (
1314
)
1415

1516
require (
16-
github.com/aws/aws-sdk-go-v2 v1.43.5 // indirect
17+
github.com/aws/aws-sdk-go-v2 v1.43.6 // indirect
1718
github.com/aws/aws-sdk-go-v2/credentials v1.19.35 // indirect
1819
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36 // indirect
19-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 // indirect
20-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 // indirect
20+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect
21+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect
2122
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37 // indirect
2223
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16 // indirect
2324
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.36 // indirect
2425
github.com/aws/aws-sdk-go-v2/service/signin v1.5.5 // indirect
2526
github.com/aws/aws-sdk-go-v2/service/sso v1.33.5 // indirect
2627
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.5 // indirect
2728
github.com/aws/aws-sdk-go-v2/service/sts v1.45.5 // indirect
28-
github.com/aws/smithy-go v1.27.7 // indirect
29+
github.com/aws/smithy-go v1.27.8 // indirect
2930
github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect
3031
github.com/docker/cli v29.6.1+incompatible // indirect
3132
github.com/docker/distribution v2.8.2+incompatible // indirect

go.sum

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
2-
github.com/aws/aws-sdk-go-v2 v1.43.5 h1:yKT5GYnFWhuDo+DqKvE5ZPwVn3RjC4MAeBtZGlh6AVM=
3-
github.com/aws/aws-sdk-go-v2 v1.43.5/go.mod h1:wZjAJppCntyOGgVSmgVTfDyRJK5PHOasO6Wsy8U7Axk=
2+
github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ=
3+
github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00=
44
github.com/aws/aws-sdk-go-v2/config v1.32.36 h1:mX6ietU7UlB4w/2IUaexJdsyUDvhTd+jYPjVePiyi6s=
55
github.com/aws/aws-sdk-go-v2/config v1.32.36/go.mod h1:rMpV4xk7ZK59edraSaHP0jsWrztWTT5tbCwWY495hug=
66
github.com/aws/aws-sdk-go-v2/credentials v1.19.35 h1:Cxua2RVdRwL0sfjHM/SnQoOnQ7xKng9m5EQBO8BnZlg=
77
github.com/aws/aws-sdk-go-v2/credentials v1.19.35/go.mod h1:9XQ+RSIGPkycr+oCJYnB1uTv5kMVVR+rd2vYK0Hxj2w=
88
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36 h1:gucL1KH/PAYbpTpBg09CiVpBdTu4qkCl8C7xOTBixUg=
99
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36/go.mod h1:usTB+PHhNMhrx2dxUeHcM7OrT5pySvmjYI++IsefPN0=
10-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 h1:5CrzwxDqf4w3x1Vs3/NiZ0nsC34Hbm3pIDMWbsLebOE=
11-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36/go.mod h1:A3gHdKZIvG/QXERzZwcxNS3RNDFcRCuhhTFBYp+V/nw=
12-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 h1:A4N2f4YPcST0v+dWtX+xrpPPCL9VTBhoIFFUWYqbacE=
13-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36/go.mod h1:B/Qr859uxWUEfZeGotK5KAEoof4Q9YWgNtPSwV6jcyk=
10+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8=
11+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA=
12+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds=
13+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8=
1414
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37 h1:oyd3ke4V9AhKcRR7rRgxk1VyI+DjK2CBQtbxh3OkdaA=
1515
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37/go.mod h1:aA9D7SqfG9IC1b7FLD7Iyc8Q4JN0a8gHhNjN4zPlIaI=
1616
github.com/aws/aws-sdk-go-v2/service/codecommit v1.37.0 h1:fiCzC5nROsrtYd2FstZG7Cz0I3SGjXW7yAu9GXOnXRw=
1717
github.com/aws/aws-sdk-go-v2/service/codecommit v1.37.0/go.mod h1:cLDPY8QqJBgAZBd+3wue1eik9fF2GualKH0K0g3ykVc=
18+
github.com/aws/aws-sdk-go-v2/service/ecr v1.60.6 h1:RbjO6G1wu+q43r0322sDABXi9vq/YZp254BBKAeLtRI=
19+
github.com/aws/aws-sdk-go-v2/service/ecr v1.60.6/go.mod h1:snsosIuclt9tpFKzldCnu1ykT5SYEND/bK9qTJmoJ+o=
1820
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16 h1:iE4NGbvqUZnHDqddQAauZzCILYtFjOHwRM5MOOKLB5A=
1921
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16/go.mod h1:VsjEgrP+ibcou8TlWA4tYaB+0OojuhirsmCe+U60hTA=
2022
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.36 h1:fx2ujmozWn+C/GtfXfz5k6Ckzza40ElOpIW7d92fLWQ=
@@ -27,8 +29,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.5 h1:8xo1q9ttkYqMJ6vOXX67FPSp
2729
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.5/go.mod h1:hbBeEUrZg6VddXYZpbKPyF0tl4XEnM+Dbx92RW3vmZI=
2830
github.com/aws/aws-sdk-go-v2/service/sts v1.45.5 h1:eQ5BtXDrPg2wK0AjtVPzeBhUpYPeqHE/ptiH7xJRGek=
2931
github.com/aws/aws-sdk-go-v2/service/sts v1.45.5/go.mod h1:f9ImhnOISY7BuTZLM8qHepCYnglHBVLk5wVzatmP++w=
30-
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
31-
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
32+
github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY=
33+
github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
3234
github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k=
3335
github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o=
3436
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=

0 commit comments

Comments
 (0)