From 2492a30ea38d8dce384a3f30d4f74b9d74ac6458 Mon Sep 17 00:00:00 2001 From: ThullyoCunha Date: Tue, 28 Apr 2026 18:12:28 -0300 Subject: [PATCH 1/3] feat(webapp): apply default repository policy on ECR repo creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosters that run the webapp's ECR account separately from their EKS worker account hit a 403 Forbidden on every new project's first run: `ensureEcrRepositoryExists` calls CreateRepository but never sets a repository policy, so kubelet can't pull the runner image cross-account. Add an optional `DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY` env var (raw IAM policy JSON, V4 mirror as well). When set, the webapp calls SetRepositoryPolicy after CreateRepository, baking the operator's cross-account pull rule into every new repo automatically. Existing repos are unaffected — they keep their current policy. Cloud is unaffected — the env var is optional and unset by default. Verified locally against a self-host on EKS with cross-account ECR: without the policy, runners stayed in ImagePullBackOff with 403; with it, the same flow completes a hello-world run end-to-end in ~5s. --- apps/webapp/app/env.server.ts | 5 ++++ .../app/v3/getDeploymentImageRef.server.ts | 29 ++++++++++++++++++- apps/webapp/app/v3/registryConfig.server.ts | 3 ++ docs/self-hosting/env/webapp.mdx | 1 + 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 1807f0a54c4..52ca0cc776e 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -300,6 +300,7 @@ const EnvironmentSchema = z DEPLOY_REGISTRY_ECR_TAGS: z.string().optional(), // csv, for example: "key1=value1,key2=value2" DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: z.string().optional(), DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: z.string().optional(), + DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY: z.string().optional(), // raw IAM policy JSON applied to every repo created by the webapp // Deployment registry (v4) - falls back to v3 registry if not specified V4_DEPLOY_REGISTRY_HOST: z @@ -332,6 +333,10 @@ const EnvironmentSchema = z .string() .optional() .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID), + V4_DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY), // Compute gateway (template creation during deploy finalize) COMPUTE_GATEWAY_URL: z.string().optional(), diff --git a/apps/webapp/app/v3/getDeploymentImageRef.server.ts b/apps/webapp/app/v3/getDeploymentImageRef.server.ts index 70e6023891b..42af2357828 100644 --- a/apps/webapp/app/v3/getDeploymentImageRef.server.ts +++ b/apps/webapp/app/v3/getDeploymentImageRef.server.ts @@ -8,6 +8,7 @@ import { GetAuthorizationTokenCommand, PutLifecyclePolicyCommand, PutImageTagMutabilityCommand, + SetRepositoryPolicyCommand, } from "@aws-sdk/client-ecr"; import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts"; import { tryCatch } from "@trigger.dev/core"; @@ -138,6 +139,7 @@ export async function getDeploymentImageRef({ roleArn: registry.ecrAssumeRoleArn, externalId: registry.ecrAssumeRoleExternalId, }, + defaultRepositoryPolicy: registry.ecrDefaultRepositoryPolicy, }) ); @@ -219,12 +221,14 @@ async function createEcrRepository({ accountId, registryTags, assumeRole, + defaultRepositoryPolicy, }: { repositoryName: string; region: string; accountId?: string; registryTags?: string; assumeRole?: AssumeRoleConfig; + defaultRepositoryPolicy?: string; }): Promise { const ecr = await createEcrClient({ region, assumeRole }); @@ -262,6 +266,20 @@ async function createEcrRepository({ }) ); + // Apply an operator-provided IAM policy to the new repository. Useful for + // self-hosters whose ECR account is separate from the account running the + // EKS workers — without this the workers get 403 Forbidden when pulling the + // task image (default ECR policy only grants access to the registry owner). + if (defaultRepositoryPolicy) { + await ecr.send( + new SetRepositoryPolicyCommand({ + repositoryName: result.repository.repositoryName, + registryId: result.repository.registryId, + policyText: defaultRepositoryPolicy, + }) + ); + } + return result.repository; } @@ -386,11 +404,13 @@ async function ensureEcrRepositoryExists({ registryHost, registryTags, assumeRole, + defaultRepositoryPolicy, }: { repositoryName: string; registryHost: string; registryTags?: string; assumeRole?: AssumeRoleConfig; + defaultRepositoryPolicy?: string; }): Promise<{ repo: Repository; repoCreated: boolean }> { const { region, accountId } = parseEcrRegistryDomain(registryHost); @@ -428,7 +448,14 @@ async function ensureEcrRepositoryExists({ } const [createRepoError, newRepo] = await tryCatch( - createEcrRepository({ repositoryName, region, accountId, registryTags, assumeRole }) + createEcrRepository({ + repositoryName, + region, + accountId, + registryTags, + assumeRole, + defaultRepositoryPolicy, + }) ); if (createRepoError) { diff --git a/apps/webapp/app/v3/registryConfig.server.ts b/apps/webapp/app/v3/registryConfig.server.ts index 72e2abdaee8..eb7986edd06 100644 --- a/apps/webapp/app/v3/registryConfig.server.ts +++ b/apps/webapp/app/v3/registryConfig.server.ts @@ -8,6 +8,7 @@ export type RegistryConfig = { ecrTags?: string; ecrAssumeRoleArn?: string; ecrAssumeRoleExternalId?: string; + ecrDefaultRepositoryPolicy?: string; }; export function getRegistryConfig(isV4Deployment: boolean): RegistryConfig { @@ -20,6 +21,7 @@ export function getRegistryConfig(isV4Deployment: boolean): RegistryConfig { ecrTags: env.V4_DEPLOY_REGISTRY_ECR_TAGS, ecrAssumeRoleArn: env.V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, ecrAssumeRoleExternalId: env.V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, + ecrDefaultRepositoryPolicy: env.V4_DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY, }; } @@ -31,5 +33,6 @@ export function getRegistryConfig(isV4Deployment: boolean): RegistryConfig { ecrTags: env.DEPLOY_REGISTRY_ECR_TAGS, ecrAssumeRoleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, ecrAssumeRoleExternalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, + ecrDefaultRepositoryPolicy: env.DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY, }; } diff --git a/docs/self-hosting/env/webapp.mdx b/docs/self-hosting/env/webapp.mdx index 16a54e0f9b9..3a45bf7b04b 100644 --- a/docs/self-hosting/env/webapp.mdx +++ b/docs/self-hosting/env/webapp.mdx @@ -76,6 +76,7 @@ mode: "wide" | `DEPLOY_REGISTRY_USERNAME` | No | — | Deploy registry username. | | `DEPLOY_REGISTRY_PASSWORD` | No | — | Deploy registry password. | | `DEPLOY_REGISTRY_NAMESPACE` | No | trigger | Deploy registry namespace. | +| `DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY` | No | — | Raw IAM policy JSON applied via SetRepositoryPolicy to every ECR repo created by the webapp. Use to grant cross-account pull access to EKS workers when the ECR account is separate from the cluster account. | | `DEPLOY_IMAGE_PLATFORM` | No | linux/amd64 | Deploy image platform, same values as docker `--platform` flag. | | `DEPLOY_TIMEOUT_MS` | No | 480000 (8m) | Deploy timeout (ms). | | **Object store (S3)** | | | | From 18f7bef92fc2bc572f07e7fe4f6d59af1f9f7b22 Mon Sep 17 00:00:00 2001 From: ThullyoCunha Date: Wed, 29 Apr 2026 09:41:41 -0300 Subject: [PATCH 2/3] fix(webapp): reconcile ECR default policy on existing repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors how the existing-repo branch already reconciles cache settings. SetRepositoryPolicy is idempotent, so applying it on every deploy is safe and covers two recovery cases that the previous version didn't: 1. A previous repo creation succeeded but SetRepositoryPolicy failed mid-flight, leaving the repo without a policy. Without reconciliation, the existing-repo branch would just return the repo and runners would keep getting 403 Forbidden forever — manual intervention required. 2. The operator updates DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY to grant pull to additional accounts/principals. Existing repos need to pick up the new value, not just freshly created ones. The factored `applyEcrRepositoryPolicy` helper is shared between the create and reconcile call sites, keeping behavior identical. --- .../app/v3/getDeploymentImageRef.server.ts | 66 +++++++++++++++++-- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/v3/getDeploymentImageRef.server.ts b/apps/webapp/app/v3/getDeploymentImageRef.server.ts index 42af2357828..d6531b8384b 100644 --- a/apps/webapp/app/v3/getDeploymentImageRef.server.ts +++ b/apps/webapp/app/v3/getDeploymentImageRef.server.ts @@ -270,19 +270,46 @@ async function createEcrRepository({ // self-hosters whose ECR account is separate from the account running the // EKS workers — without this the workers get 403 Forbidden when pulling the // task image (default ECR policy only grants access to the registry owner). + // The existing-repo branch of `ensureEcrRepositoryExists` reconciles this + // same policy on every call, so a partial-create that fails here is + // self-healing on the next deploy. if (defaultRepositoryPolicy) { - await ecr.send( - new SetRepositoryPolicyCommand({ - repositoryName: result.repository.repositoryName, - registryId: result.repository.registryId, - policyText: defaultRepositoryPolicy, - }) - ); + await applyEcrRepositoryPolicy({ + repositoryName: result.repository.repositoryName!, + region, + accountId: result.repository.registryId ?? accountId, + assumeRole, + defaultRepositoryPolicy, + }); } return result.repository; } +async function applyEcrRepositoryPolicy({ + repositoryName, + region, + accountId, + assumeRole, + defaultRepositoryPolicy, +}: { + repositoryName: string; + region: string; + accountId?: string; + assumeRole?: AssumeRoleConfig; + defaultRepositoryPolicy: string; +}): Promise { + const ecr = await createEcrClient({ region, assumeRole }); + + await ecr.send( + new SetRepositoryPolicyCommand({ + repositoryName, + registryId: accountId, + policyText: defaultRepositoryPolicy, + }) + ); +} + async function updateEcrRepositoryCacheSettings({ repositoryName, region, @@ -441,6 +468,31 @@ async function ensureEcrRepositoryExists({ } } + // Reconcile the default repository policy on every call. Idempotent, and + // covers two recovery cases: (1) a previous create succeeded but the + // SetRepositoryPolicy call failed mid-flight, leaving the repo without a + // policy; (2) the operator updated DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY + // and existing repos need to pick up the new value. + if (defaultRepositoryPolicy) { + const [policyError] = await tryCatch( + applyEcrRepositoryPolicy({ + repositoryName, + region, + accountId, + assumeRole, + defaultRepositoryPolicy, + }) + ); + + if (policyError) { + logger.error("Failed to reconcile ECR repository policy on existing repo", { + repositoryName, + region, + policyError, + }); + } + } + return { repo: existingRepo, repoCreated: false, From 229922194c89d0324ab5f50e68ab89b6b28576f6 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:13:53 +0100 Subject: [PATCH 3/3] chore: add server changes file --- .server-changes/ecr-default-repository-policy.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/ecr-default-repository-policy.md diff --git a/.server-changes/ecr-default-repository-policy.md b/.server-changes/ecr-default-repository-policy.md new file mode 100644 index 00000000000..0ec2d04659a --- /dev/null +++ b/.server-changes/ecr-default-repository-policy.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Optional `DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY` env var to apply a default repository policy when the webapp creates new ECR repos