A custom domain name in front of ECR
Amazon Web Services’ ECR (Elastic Container Registry) is a great solution for hosting a private docker image registry.
At Codeface we use ECR to host ready-made development environments for all our projects.
On each project we have a docker-compose.yml
file that will contain a line like:
image: 123456789012.dkr.ecr.eu-west-2.amazonaws.com/myapp:latest
When someone dives in to work on one of our projects, they just grab the ready-made development environment
from that ECR address, and don’t have to wait for a docker build
to complete.
But that is one ugly looking url, and it just feels a bit wrong to have such a specific ECR address baked into our code. What we really wanted to see in there was something more like…
image: container-registry.codeface.com/myapp:latest
That would give us more flexibility in future - if we decided to use ECR on a different AWS account, or use a different service altogether, we wouldn’t have to change the address in potentially dozens of different places.
It turns out that right now (early 2024), Amazon don’t make it easy to have a custom domain like that in front of ECR. Or at least, they don’t make it trivial - there’s no built-in setting to allow this.
If you simply add a CNAME to point at the relevant registry url, unsurprisingly the first hurdle is certificate errors:
Error response from daemon: Get "https://custom-name.codeface.com/v2/": x509:
certificate is valid for *.dkr.ecr.eu-west-2.amazonaws.com, *.dkr.ecr.eu-west-2.vpce.amazonaws.com,
not custom-name.codeface.com
And there’s no option available at ECR to add a custom name and attach a certificate from Amazon Certificate Manager or anything handy like that.
However, AWS does have a couple of services that spring to mind where you can set up a custom name and attach a certificate - namely, CloudFront and API Gateway.
And fortunately some smart people on this discussion of the issue on github have mapped out some solutions. In particular, the solutions from amancevice and naftulikay inspired me to have a go.
The proposed solution from amancevice is packaged up using Terraform, and the one from naftulikay uses a Lambda function running a docker image. These were not quite what I wanted, but gave me enough confidence to try setting up a basic solution from scratch, just using the AWS web control panel. It turns out not to be too scary at all. Here’s what you need to do:
Set up a simple Lambda function
Set up simple Lambda function to do a 307 redirect for all requests. I grabbed this from the source code of the Terraform project mentioned above:
"use strict";
const AWS_ECR_REGISTRY = process.env.AWS_ECR_REGISTRY;
exports.handler = (event, context, callback) => {
console.log(`EVENT ${JSON.stringify(event)}`);
const path = event.rawPath;
const location = `https://${AWS_ECR_REGISTRY}${path}`;
const redirect = { statusCode: 307, headers: { location: location } };
callback(null, redirect);
};
(NB When I first tried setting this up, I pasted in the code and it got automatically named as a .mjs which didn’t work, so I manually renamed it as .js which fixed the problem)
Set up an API using API Gateway
So now we’ve got a Lambda function which will do a 307 redirect for all requests. Lambda offers an option “Configure Function URL” which gives us a quick and easy way to attach an https url to our lambda function. However there’s no option to add a custom domain name to that, so we need to use another AWS service, API Gateway.
(An alternative might be CloudFront, but it sounded from the discussion that people were getting intermittent problems with that)
I’d actually never used API Gateway before. I was pleasantly surprised at how easy it was to set up an API.
It went something like this:
Create the API
We just need one HTTP API which we’ll configure to handle any requests with our Lambda function.
- Click “Create API”; Click “Build” under HTTP API
- Give it a name, and click “Review and Create”
Create the default route
We’ll just need one route, a default route which handles all incoming requests:
- In our newly created API, click Routes then “Create”
- For the path, enter
$default
and click the orange “Create” button.
Attach the Lambda function to the default route
We want any requests to our default route to be handled by our Lambda function; to achieve that we create an “integration” and link the route to it:
- Click on the
$default
route that we created in the previous step - Click “Attach Integration” and then “Create and Attach an Integration”
- Select “Lambda Function” as the integration type and select the Lambda function that we set up earlier
- Click “Create”
Create a Custom Host Name
Before setting up a Custom domain name in API gateway, you first need to get a certificate for the name you plan to use. Once we’ve done that, we can set up the custom name:
- Click Custom domain names in the menu.
- Enter Domain name, e.g.
container-registry.codeface.com
- Leave the API endpoint type as ‘Regional’.
- Select an appropriate certificate
- Create Domain Name
Set up a CNAME
at domain registrar
Now that our Custom Domain Name has been set up at API Gateway, we can grab the ‘API Gateway domain name’
and that’s what we need to point our custom hostname at at the registrar. This will be a host name something like
d-abc123aaaa.execute-api.eu-west-2.amazonaws.com
Once that CNAME has been set up, we can check it with dig container-registry.codeface.com cname
and expect to get an answer something like…
;; ANSWER SECTION:
container-registry.codeface.com. 600 IN CNAME d-abc0aa0aa0.execute-api.eu-west-2.amazonaws.com.
So, now any request for container-registry.codeface.com/blah will get a 307 redirect to the corresponding backend ECR address.
I can confirm that with
curl -v https://container-registry.codeface.com/blah
and in the response I see…
< HTTP/2 307
< content-length: 0
< location: https://123456789012.dkr.ecr.eu-west-2.amazonaws.com/blah
It works!
So now, once I’ve logged in using… aws ecr get-login-password --region eu-west-2 | docker login --username AWS --password-stdin container-registry.codeface.com
…I can do e.g….
docker pull container-registry.codeface.com/my-project:latest
docker push container-registry.codeface.com/my-project:latest
And I can update all my docker-compose.yml
files with the much neater:
image: container-registry.codeface.com/myapp:latest
Super-satisfying to have this up and running. It felt like something that ought to be possible, but I must confess I went round in circles a fair bit before discovering the solution described here!