├── .github └── workflows │ └── test.yml ├── .gitignore ├── .node-version ├── LICENSE ├── Makefile ├── README.md ├── bin └── docker-credential-ecr-custom ├── example └── terraform.tf ├── main.tf ├── outputs.tf ├── src ├── Makefile ├── index.js └── index.test.js └── variables.tf /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | push: 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: "18" 13 | - run: make test 14 | validate: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: hashicorp/setup-terraform@v2 19 | - run: make validate 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/ 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | 8 | # .tfvars files 9 | *.tfvars 10 | 11 | .docker/ 12 | .venv/ 13 | __pycache__/ 14 | *.coverage 15 | *.env 16 | *.iid 17 | *.zip 18 | .terraform.lock.hcl 19 | coverage.xml 20 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.11.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alexander Mancevice 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: test validate 2 | 3 | clean: 4 | rm -rf .terraform* 5 | 6 | test: 7 | make -C src test 8 | 9 | validate: | .terraform 10 | terraform fmt -check 11 | AWS_REGION=us-east-1 terraform validate 12 | 13 | .PHONY: test validate 14 | 15 | .terraform: 16 | terraform init 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom DNS for ECR registry 2 | 3 | [![terraform](https://img.shields.io/github/v/tag/amancevice/terraform-aws-custom-ecr-domain?color=62f&label=version&logo=terraform&style=flat-square)](https://registry.terraform.io/modules/amancevice/custom-ecr-domain/aws) 4 | [![test](https://img.shields.io/github/actions/workflow/status/amancevice/terraform-aws-custom-ecr-domain/test.yml?logo=github&style=flat-square)](https://github.com/amancevice/terraform-aws-custom-ecr-domain/actions/workflows/test.yml) 5 | 6 | Set up a custom DNS entry for a ECR. 7 | 8 | Instead of: 9 | 10 | ```bash 11 | docker pull 123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo 12 | ``` 13 | 14 | Use API Gateway HTTP APIs and Lambda to alias your registry with DNS: 15 | 16 | ```bash 17 | docker pull ecr.example.com/my-repo 18 | ``` 19 | 20 | ## How 21 | 22 | [This post](https://httptoolkit.com/blog/docker-image-registry-facade/) describes why using a `CNAME` record alone won't work with a Docker registry. 23 | 24 | Instead, we will use a regional API Gateway HTTP API to proxy the request through a Lambda function that responds to ANY request with a [`307` temporary redirect](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/307), replacing the original request hostname with the configured ECR registry. 25 | 26 | The redirect will preserve the method and body of the original request, allowing us to push and pull Docker images with ECR as the backend. 27 | 28 | ## Usage 29 | 30 | See the [example](./example) directory for an example project. 31 | 32 | ```terraform 33 | data "aws_acm_certificate" "ssl" { 34 | domain = "example.com" 35 | statuses = ["ISSUED"] 36 | } 37 | 38 | data "aws_route53_zone" "zone" { 39 | name = "example.com." 40 | } 41 | 42 | module "custom-ecr-domain" { 43 | source = "amancevice/custom-ecr-domain/aws" 44 | api_name = "ecr-proxy" 45 | domain_name = "ecr.example.com" 46 | domain_certificate_arn = data.aws_acm_certificate.ssl.arn 47 | domain_zone_id = data.aws_route53_zone.zone.id 48 | function_name = "ecr-proxy" 49 | log_retention_in_days = 14 50 | } 51 | ``` 52 | 53 | ## Authentication 54 | 55 | You can use the AWS CLI to generate passwords to pass to `docker login`, but using a [credential helper](https://docs.docker.com/engine/reference/commandline/login/) is a much easier way of using Docker & ECR. 56 | 57 | AWS provides a [tool](https://github.com/awslabs/amazon-ecr-credential-helper) to authenticate between Docker and ECR, but this helper requires repositories use the AWS-style `123456789012.dkr.ecr.us-east-1.amazonaws.com` registry names. 58 | 59 | > _There is an open ticket ([#504](https://github.com/awslabs/amazon-ecr-credential-helper/pull/504)) to allow users to configure the offical tool to enale a default registry._ 60 | 61 | This repo provides a wrapper script that can be used with a custom registry. 62 | 63 | To use the credential helper: 64 | 65 | - Clone this repo 66 | - Copy `bin/docker-credential-ecr-custom` somewhere on your `$PATH` (eg, `/usr/local/bin`) 67 | - Create the config file `~/.ecr/custom.json` with mappings of your custom domains and ECR registries 68 | - Update your Docker config `credHelpers` section to use `ecr-custom` 69 | 70 | Example `~/.ecr/custom.json`: 71 | 72 | ```json 73 | { 74 | "ecr.example.com": "123456789012.dkr.ecr.us-east-1.amazonaws.com" 75 | } 76 | ``` 77 | 78 | Example `~/.docker/config.json` snippet: 79 | 80 | ```json 81 | { 82 | "credHelpers": { 83 | "ecr.example.com": "ecr-custom" 84 | } 85 | } 86 | ``` 87 | -------------------------------------------------------------------------------- /bin/docker-credential-ecr-custom: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eo pipefail 4 | 5 | ############### 6 | # HELPERS # 7 | ############### 8 | 9 | getEcrLoginPath() { 10 | command -v docker-credential-ecr-login \ 11 | || echo >&2 'docker-credential-ecr-login is not installed' 12 | } 13 | 14 | getJqPath() { 15 | command -v jq \ 16 | || echo >&2 'jq is not installed' 17 | } 18 | 19 | getRegistry() { 20 | local customName="$(cat /dev/stdin)" 21 | getServerURLs | "$JQ" -r ".\"$customName\"" 22 | } 23 | 24 | getServerURLs() { 25 | if [ -f "$ECR_CONFIG" ] 26 | then 27 | cat "$ECR_CONFIG" 28 | else 29 | echo >&2 "no ecr-custom config found at $ECR_CONFIG" 30 | echo '{}' 31 | fi 32 | } 33 | 34 | showUsage() { 35 | echo >&2 "Usage: $BASENAME " 36 | return 1 37 | } 38 | 39 | showUnknownAction() { 40 | echo >&2 "Unknown credential action \`$1\`" 41 | } 42 | 43 | ###################### 44 | # CUSTOM ACTIONS # 45 | ###################### 46 | 47 | init() { 48 | local registry alias config="$(getServerURLs 2> /dev/null)" 49 | printf 'Enter custom registry (ecr.example.com): ' 50 | read alias 51 | printf 'Enter ECR registry (123456789012.dkr.ecr.us-east-1.amazonaws.com): ' 52 | read registry 53 | (echo "$config" ; jq -n "{\"$alias\":\"$registry\"}") | jq -s --tab add > "$ECR_CONFIG" 54 | jq . "$ECR_CONFIG" 55 | } 56 | 57 | get() { 58 | getRegistry | "$ECR_LOGIN" get 59 | } 60 | 61 | list() { 62 | getServerURLs | "$JQ" 'with_entries({key: ("https://"+.key), value: "AWS"})' 63 | } 64 | 65 | version() { 66 | echo "$VERSION" 67 | } 68 | 69 | ############ 70 | # MAIN # 71 | ############ 72 | 73 | VERSION='0.3.0' 74 | BASENAME="$(basename $0)" 75 | ECR_CONFIG="${AWS_ECR_CUSTOM_CONFIG:-$HOME/.ecr/custom.json}" 76 | ECR_DEFAULT="$AWS_ECR_CUSTOM_DEFAULT" 77 | 78 | ECR_LOGIN="$(getEcrLoginPath)" 79 | JQ="$(getJqPath)" 80 | 81 | main() { 82 | test -n "$ECR_LOGIN" || return 1 83 | test -n "$JQ" || return 1 84 | case "$1" in 85 | '') showUsage ;; 86 | init|get|list|version) "$@" ;; 87 | store|erase) ;; 88 | *) showUnknownAction "$1" ;; 89 | esac 90 | } 91 | 92 | main "$@" 93 | -------------------------------------------------------------------------------- /example/terraform.tf: -------------------------------------------------------------------------------- 1 | ################# 2 | # TERRAFORM # 3 | ################# 4 | 5 | terraform { 6 | required_version = "~> 1.0" 7 | 8 | required_providers { 9 | aws = { 10 | source = "hashicorp/aws" 11 | version = "~> 5.0" 12 | } 13 | } 14 | } 15 | 16 | ########### 17 | # AWS # 18 | ########### 19 | 20 | provider "aws" { 21 | region = local.region 22 | 23 | default_tags { tags = { Name = local.name } } 24 | } 25 | 26 | ############## 27 | # LOCALS # 28 | ############## 29 | 30 | locals { 31 | region = "us-east-1" 32 | name = "custom-ecr-domain" 33 | 34 | api_name = local.name 35 | function_name = local.api_name 36 | function_role_name = "${local.region}-${local.function_name}" 37 | } 38 | 39 | ############ 40 | # DATA # 41 | ############ 42 | 43 | data "aws_caller_identity" "current" {} 44 | data "aws_region" "current" {} 45 | 46 | ######################### 47 | # CUSTOM ECR DOMAIN # 48 | ######################### 49 | 50 | variable "domain_name" { type = string } 51 | 52 | data "aws_acm_certificate" "ssl" { 53 | domain = var.domain_name 54 | statuses = ["ISSUED"] 55 | } 56 | 57 | data "aws_route53_zone" "zone" { 58 | name = "${var.domain_name}." 59 | } 60 | 61 | module "custom-ecr-domain" { 62 | source = "./.." 63 | 64 | domain_name = "ecr.${var.domain_name}" 65 | domain_certificate_arn = data.aws_acm_certificate.ssl.arn 66 | domain_zone_id = data.aws_route53_zone.zone.id 67 | api_name = local.api_name 68 | function_name = local.function_name 69 | function_role_name = local.function_role_name 70 | log_retention_in_days = 14 71 | } 72 | 73 | ############################ 74 | # CUSTOM ECR REPO TEST # 75 | ############################ 76 | 77 | resource "aws_ecr_repository" "test" { 78 | name = "test" 79 | } 80 | 81 | ############### 82 | # OUTPUTS # 83 | ############### 84 | 85 | output "image" { value = "ecr.${var.domain_name}/${aws_ecr_repository.test.name}" } 86 | -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | ################# 2 | # TERRAFORM # 3 | ################# 4 | 5 | terraform { 6 | required_version = "~> 1.0" 7 | 8 | required_providers { 9 | aws = { 10 | source = "hashicorp/aws" 11 | version = "~> 5.0" 12 | } 13 | } 14 | } 15 | 16 | ########### 17 | # AWS # 18 | ########### 19 | 20 | data "aws_caller_identity" "current" {} 21 | data "aws_region" "current" {} 22 | 23 | ################### 24 | # API GATEWAY # 25 | ################### 26 | 27 | resource "aws_apigatewayv2_api" "api" { 28 | description = var.api_description 29 | disable_execute_api_endpoint = true 30 | name = var.api_name 31 | protocol_type = "HTTP" 32 | tags = var.tags 33 | } 34 | 35 | resource "aws_apigatewayv2_api_mapping" "api" { 36 | api_id = aws_apigatewayv2_api.api.id 37 | domain_name = aws_apigatewayv2_domain_name.api.domain_name 38 | stage = aws_apigatewayv2_stage.default.name 39 | } 40 | 41 | resource "aws_apigatewayv2_domain_name" "api" { 42 | domain_name = var.domain_name 43 | 44 | domain_name_configuration { 45 | certificate_arn = var.domain_certificate_arn 46 | endpoint_type = "REGIONAL" 47 | security_policy = "TLS_1_2" 48 | } 49 | } 50 | 51 | resource "aws_apigatewayv2_route" "proxy" { 52 | api_id = aws_apigatewayv2_api.api.id 53 | route_key = "ANY /{proxy+}" 54 | target = "integrations/${aws_apigatewayv2_integration.proxy.id}" 55 | } 56 | 57 | resource "aws_apigatewayv2_integration" "proxy" { 58 | api_id = aws_apigatewayv2_api.api.id 59 | description = var.function_description 60 | integration_method = "POST" 61 | integration_type = "AWS_PROXY" 62 | integration_uri = aws_lambda_function.proxy.invoke_arn 63 | payload_format_version = "2.0" 64 | } 65 | 66 | resource "aws_apigatewayv2_stage" "default" { 67 | api_id = aws_apigatewayv2_api.api.id 68 | auto_deploy = var.api_auto_deploy 69 | description = var.api_description 70 | name = "$default" 71 | 72 | access_log_settings { 73 | destination_arn = aws_cloudwatch_log_group.api.arn 74 | format = jsonencode(var.api_log_format) 75 | } 76 | 77 | lifecycle { 78 | ignore_changes = [deployment_id] 79 | } 80 | } 81 | 82 | ########### 83 | # DNS # 84 | ########### 85 | 86 | resource "aws_route53_record" "records" { 87 | for_each = toset(["A", "AAAA"]) 88 | name = aws_apigatewayv2_domain_name.api.domain_name 89 | set_identifier = data.aws_region.current.name 90 | type = each.key 91 | zone_id = var.domain_zone_id 92 | 93 | alias { 94 | evaluate_target_health = false 95 | name = aws_apigatewayv2_domain_name.api.domain_name_configuration[0].target_domain_name 96 | zone_id = aws_apigatewayv2_domain_name.api.domain_name_configuration[0].hosted_zone_id 97 | } 98 | 99 | latency_routing_policy { 100 | region = data.aws_region.current.name 101 | } 102 | } 103 | 104 | ############## 105 | # LAMBDA # 106 | ############## 107 | 108 | locals { 109 | default_ecr_registry = "${data.aws_caller_identity.current.account_id}.dkr.ecr.${data.aws_region.current.name}.amazonaws.com" 110 | ecr_registry = coalesce(var.ecr_registry, local.default_ecr_registry) 111 | } 112 | 113 | data "archive_file" "proxy" { 114 | source_file = "${path.module}/src/index.js" 115 | output_path = "${path.module}/src/package.zip" 116 | type = "zip" 117 | } 118 | 119 | resource "aws_iam_role" "proxy" { 120 | name = coalesce(var.function_role_name, var.function_name) 121 | 122 | assume_role_policy = jsonencode({ 123 | Version = "2012-10-17" 124 | Statement = { 125 | Effect = "Allow" 126 | Action = "sts:AssumeRole" 127 | 128 | Principal = { 129 | Service = [ 130 | "edgelambda.amazonaws.com", 131 | "lambda.amazonaws.com", 132 | ] 133 | } 134 | } 135 | }) 136 | } 137 | 138 | resource "aws_iam_role_policy" "proxy" { 139 | name = "logs" 140 | role = aws_iam_role.proxy.id 141 | 142 | policy = jsonencode({ 143 | Version = "2012-10-17" 144 | Statement = { 145 | Sid = "Logs" 146 | Effect = "Allow" 147 | Action = "logs:*" 148 | Resource = "*" 149 | } 150 | }) 151 | } 152 | 153 | resource "aws_lambda_function" "proxy" { 154 | architectures = ["arm64"] 155 | description = var.function_description 156 | filename = data.archive_file.proxy.output_path 157 | function_name = var.function_name 158 | handler = "index.handler" 159 | role = aws_iam_role.proxy.arn 160 | runtime = var.function_runtime 161 | source_code_hash = data.archive_file.proxy.output_base64sha256 162 | 163 | environment { 164 | variables = { AWS_ECR_REGISTRY = local.ecr_registry } 165 | } 166 | } 167 | 168 | resource "aws_lambda_permission" "proxy" { 169 | action = "lambda:InvokeFunction" 170 | function_name = aws_lambda_function.proxy.function_name 171 | principal = "apigateway.amazonaws.com" 172 | source_arn = "${aws_apigatewayv2_api.api.execution_arn}/$default/ANY/{proxy+}" 173 | } 174 | 175 | ############ 176 | # LOGS # 177 | ############ 178 | 179 | resource "aws_cloudwatch_log_group" "api" { 180 | name = "/aws/apigatewayv2/${aws_apigatewayv2_api.api.name}" 181 | retention_in_days = var.log_retention_in_days 182 | } 183 | 184 | resource "aws_cloudwatch_log_group" "proxy" { 185 | name = "/aws/lambda/${aws_lambda_function.proxy.function_name}" 186 | retention_in_days = var.log_retention_in_days 187 | } 188 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | output "api" { 2 | description = "API Gateway API" 3 | value = aws_apigatewayv2_api.api 4 | } 5 | 6 | output "api_domain" { 7 | description = "API Gateway custom domain" 8 | value = aws_apigatewayv2_domain_name.api 9 | } 10 | 11 | output "api_stage" { 12 | description = "API Gateway stage" 13 | value = aws_apigatewayv2_stage.default 14 | } 15 | 16 | output "proxy" { 17 | description = "Lambda function" 18 | value = aws_lambda_function.proxy 19 | } 20 | 21 | output "log_groups" { 22 | description = "CloudWatch log groups" 23 | value = { 24 | api = aws_cloudwatch_log_group.api 25 | proxy = aws_cloudwatch_log_group.proxy 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | node index.test.js 3 | 4 | .PHONY: test 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const AWS_ECR_REGISTRY = process.env.AWS_ECR_REGISTRY; 4 | 5 | exports.handler = (event, context, callback) => { 6 | console.log(`EVENT ${JSON.stringify(event)}`); 7 | const path = event.rawPath; 8 | const location = `https://${AWS_ECR_REGISTRY}${path}`; 9 | const redirect = { statusCode: 307, headers: { location: location } }; 10 | callback(null, redirect); 11 | }; 12 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | process.env.AWS_ECR_REGISTRY = "123456789012.dkr.ecr.us-east-1.amazonaws.com" 4 | 5 | const assert = require("assert") 6 | const index = require("./index"); 7 | 8 | const registry = process.env.AWS_ECR_REGISTRY; 9 | const event = { rawPath: '/v2' }; 10 | const expected = { statusCode: 307, headers: { location: `https://${registry}/v2` } } 11 | 12 | const callback = (_, res) => { assert.deepEqual(res, expected) } 13 | 14 | index.handler(event, {}, callback); 15 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | ############### 2 | # GENERAL # 3 | ############### 4 | 5 | variable "log_retention_in_days" { 6 | type = number 7 | description = "ECR custom log retention in days" 8 | default = 14 9 | } 10 | 11 | variable "tags" { 12 | type = map(string) 13 | description = "ECR custom domain tags" 14 | default = null 15 | } 16 | 17 | ########### 18 | # API # 19 | ########### 20 | 21 | variable "api_auto_deploy" { 22 | type = bool 23 | description = "API auto deploy" 24 | default = true 25 | } 26 | 27 | variable "api_description" { 28 | type = string 29 | description = "API description" 30 | default = "ECR custom domain proxy" 31 | } 32 | 33 | variable "api_log_format" { 34 | type = map(string) 35 | description = "ECR custom domain proxy API log format" 36 | default = { 37 | httpMethod = "$context.httpMethod" 38 | integrationErrorMessage = "$context.integrationErrorMessage" 39 | ip = "$context.identity.sourceIp" 40 | path = "$context.path" 41 | protocol = "$context.protocol" 42 | requestId = "$context.requestId" 43 | requestTime = "$context.requestTime" 44 | responseLength = "$context.responseLength" 45 | routeKey = "$context.routeKey" 46 | status = "$context.status" 47 | } 48 | } 49 | 50 | variable "api_name" { 51 | type = string 52 | description = "ECR custom domain proxy API name" 53 | } 54 | 55 | variable "api_stage_description" { 56 | type = string 57 | description = "ECR custom domain proxy API stage description" 58 | default = "ECR custom domain proxy API stage" 59 | } 60 | 61 | ########### 62 | # DNS # 63 | ########### 64 | 65 | variable "domain_certificate_arn" { 66 | type = string 67 | description = "ECR custom domain proxy API custom domain ACM certificate ARN" 68 | } 69 | 70 | variable "domain_name" { 71 | type = string 72 | description = "ECR custom domain proxy API custom domain" 73 | } 74 | 75 | variable "domain_zone_id" { 76 | type = string 77 | description = "ECR custom domain proxy API Route53 hosted zone ID" 78 | } 79 | 80 | #################### 81 | # PROXY LAMBDA # 82 | #################### 83 | 84 | variable "function_description" { 85 | description = "ECR custom domain proxy function description" 86 | type = string 87 | default = "Enhance Docker requests for ECR" 88 | } 89 | 90 | variable "function_name" { 91 | description = "ECR custom domain proxy function name" 92 | type = string 93 | } 94 | 95 | variable "function_role_name" { 96 | description = "ECR custom domain proxy role name" 97 | type = string 98 | default = null 99 | } 100 | 101 | variable "function_runtime" { 102 | description = "ECR custom domain proxy function runtime" 103 | type = string 104 | default = "nodejs22.x" 105 | } 106 | 107 | ########### 108 | # ECR # 109 | ########### 110 | 111 | variable "ecr_registry" { 112 | description = "ECR registry host" 113 | type = string 114 | default = null 115 | } 116 | --------------------------------------------------------------------------------