I tried implementing a configuration that connects using a private DNS name with AWS PrivateLink in AWS CDK
Hello, I'm Wakatsuki from the Manufacturing Business Technology Department.
With AWS PrivateLink, you can make resources within a VPC accessible from other AWS accounts or VPCs without exposing them to the internet.
From Architecture 1: AWS PrivateLink - AWS Prescriptive Guidance
In my previous blog, I introduced how to implement both the service provider side (being connected to) and the service consumer side (connecting) of AWS PrivateLink using AWS CDK.
By default, consumer-side resources connect to the VPC endpoint using an auto-generated DNS name (<endpoint-ID>-<random-value>.<endpoint-service-ID>.ap-northeast-1.vpce.amazonaws.com
), but it's also possible to set up a private DNS name using a custom domain prepared by the provider side, allowing the consumer side to connect to the VPC endpoint using that custom domain. This enables certificate-based connections, which also supports HTTPS connections.
This time, I implemented a configuration using AWS CDK where AWS PrivateLink connects using a private DNS name.
Implementation
Here, we'll go through the following implementation steps. Note that not everything can be completed with AWS CDK alone, and some manual operations are required.
- Provider side
- Create a VPC endpoint service (AWS CDK)
- Set up a private DNS name (manual)
- Create TXT records in Route 53 and perform domain validation
- Consumer side
- Create a VPC endpoint (AWS CDK)
- Enable
privateDnsEnabled
- Send a connection request to the VPC endpoint service
- Enable
- Create a VPC endpoint (AWS CDK)
- Provider side
- Accept the VPC endpoint service connection request (manual)
- Consumer side
- Verify that you can connect to the VPC endpoint using the private DNS name### Provider side (Creating a VPC endpoint service)
We assume that the provider side has already completed the following preparations:
- Host zone for custom domain is already configured in Route 53
- The consumer side AWS account ID has been received
The CDK implementation for the provider side is as follows:
import * as cdk from "aws-cdk-lib";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as elbv2_targets from "aws-cdk-lib/aws-elasticloadbalancingv2-targets";
import * as iam from "aws-cdk-lib/aws-iam";
import * as route53 from "aws-cdk-lib/aws-route53";
import { Construct } from "constructs";
const CONSUMER_ACCOUNT_ID = process.env.CONSUMER_ACCOUNT_ID;
const CUSTOM_DOMAIN_NAME = process.env.CUSTOM_DOMAIN_NAME || "";
export class PrivateLinkProviderConstruct extends Construct {
constructor(scope: Construct, id: string) {
super(scope, id);
/**
* Creating a VPC
*/
const vpc = new ec2.Vpc(this, "VPC", {
subnetConfiguration: [
// Only creating private subnets assuming connections to ALB are allowed only via PrivateLink
{
name: "PrivateIsolated",
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
],
maxAzs: 2, // Specifying minimum configuration for testing. ALB requires 2 AZs
natGateways: 0, // Setting NAT Gateway to 0 as it's not used in this test
restrictDefaultSecurityGroup: true, // Restricting default security group to minimize access permissions
});
// -----------------------------------------------
// ↓ DNS and SSL certificate configuration
// -----------------------------------------------
/**
* Referencing existing Route 53 hosted zone
*/
const hostedZone = route53.HostedZone.fromLookup(this, "HostedZone", {
domainName: CUSTOM_DOMAIN_NAME,
});
/**
* PrivateLink subdomain configuration
*/
const privateLinkDomainName = `privatelink.${CUSTOM_DOMAIN_NAME}`;
/**
* Creating SSL certificate (ACM)
*/
const certificate = new acm.Certificate(this, "Certificate", {
domainName: privateLinkDomainName,
subjectAlternativeNames: [`*.${privateLinkDomainName}`],
validation: acm.CertificateValidation.fromDns(hostedZone),
});
// -----------------------------------------------
// ↓ Implementation of the service to be exposed (ALB)
// -----------------------------------------------
/**
* Creating ALB
*/
const alb = new elbv2.ApplicationLoadBalancer(
this,
"ApplicationLoadBalancer",
{
vpc,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
}
);
/**
* Creating ALB listener
*/
alb.addListener("AlbListener", {
port: 443,
defaultAction: elbv2.ListenerAction.fixedResponse(200, {
messageBody: "Hello from ALB!",
}),
certificates: [certificate],
});
// -----------------------------------------------
// ↓ Implementation required for the PrivateLink provider side
// -----------------------------------------------
/**
* Creating NLB
*/
const nlb = new elbv2.NetworkLoadBalancer(this, "NetworkLoadBalancer", {
vpc,
internetFacing: false,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
});
/**
* Creating target group for NLB
*/
const nlbTargetGroup = new elbv2.NetworkTargetGroup(
this,
"NlbTargetGroup",
{
port: 443,
vpc,
}
);
/**
* Adding ALB as a target for NLB
*/
nlbTargetGroup.addTarget(
new elbv2_targets.AlbListenerTarget(alb.listeners[0])
);
/**
* Creating NLB listener
*/
nlb.addListener("NlbListener", {
port: 443,
defaultTargetGroups: [nlbTargetGroup],
});
/**
* Creating VPC Endpoint Service (PrivateLink provider)
*/
const vpcEndpointService = new ec2.VpcEndpointService(
this,
"VpcEndpointService",
{
vpcEndpointServiceLoadBalancers: [nlb],
acceptanceRequired: true, // Setting manual approval as required
allowedPrincipals: [
new iam.ArnPrincipal(`arn:aws:iam::${CONSUMER_ACCOUNT_ID}:root`),
],
}
);
/**
* Outputting the VPC endpoint service name
*/
new cdk.CfnOutput(this, "ProviderVpcEndpointServiceName", {
value: vpcEndpointService.vpcEndpointServiceName,
});
}
}
```In the above setup, we configure a subdomain `privatelink.<custom domain>` for PrivateLink and create an SSL certificate in ACM. We also create a listener using HTTPS (port 443) with this certificate.
Deploy the CDK with environment variables `CONSUMER_ACCOUNT_ID` set to the consumer's AWS account ID and `CUSTOM_DOMAIN_NAME` set to your custom domain.
### Provider Side (Setting up Private DNS Name)
Open the VPC endpoint service details screen created by the deployment and click **Change private DNS name**.

Check **Associate private DNS name with service**, specify the private DNS name to use, and save.

Back on the details screen, the domain verification status will show `Pending verification`, and domain verification name and value will be displayed.

Add the domain verification name and value as a TXT record to your Route 53 hosted zone. According to the documentation, the TTL value should be set to `1800`.

Return to the VPC endpoint service details screen and click **Verify domain ownership for private DNS name**.

Verify ownership. (It's unclear why entering "verification" is required here)

The process may take some time, but once domain verification is complete, the status will change to `Verified`.

The private DNS name setup is now complete. You can delete the TXT record you registered earlier.
Share the VPC endpoint service name (`com.amazonaws.vpce.ap-northeast-1.<endpoint service ID>`) and the private DNS name (`privatelink.<custom domain name>`) with the consumer.### Consumer side (creating VPC endpoint)
Once the consumer side receives the VPC endpoint service name and private DNS name from the provider side, implement it with AWS CDK as follows:
```ts:lib/constructs/privatelink-consumer.ts
import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as logs from "aws-cdk-lib/aws-logs";
import * as lambda_nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
const PROVIDER_VPC_ENDPOINT_SERVICE_NAME =
process.env.PROVIDER_VPC_ENDPOINT_SERVICE_NAME || "";
const PRIVATE_DNS_NAME = process.env.PRIVATE_DNS_NAME || "";
export class PrivateLinkConsumerConstruct extends Construct {
constructor(scope: Construct, id: string) {
super(scope, id);
/**
* Creating a VPC
*/
const vpc = new ec2.Vpc(this, "VPC", {
subnetConfiguration: [
// Creating only private subnets, assuming communications will only go through VPC endpoints
{
name: "PrivateIsolated",
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
],
maxAzs: 1, // Limiting to 1 AZ for testing purposes
natGateways: 0, // Not using NAT Gateway for testing purposes
restrictDefaultSecurityGroup: true, // Restricting default security group to minimize access permissions
});
// -----------------------------------------------
// ↓ Implementation needed for PrivateLink consumer side
// -----------------------------------------------
/**
* Security Group for VPC Endpoint
*
* MEMO: Explicitly created to restrict connections to VPC Endpoint only. (If not explicitly created, allowAllOutbound is enabled by default)
*/
const vpcEndpointSecurityGroup = new ec2.SecurityGroup(
this,
"VpcEndpointSecurityGroup",
{
vpc,
allowAllOutbound: false,
}
);
/**
* Creating a VPC Endpoint
*/
const vpcEndpoint = new ec2.InterfaceVpcEndpoint(
this,
"PrivateLinkEndpoint",
{
vpc,
service: new ec2.InterfaceVpcEndpointService(
PROVIDER_VPC_ENDPOINT_SERVICE_NAME
),
subnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
securityGroups: [vpcEndpointSecurityGroup],
open: false, // Disabling the setting that allows all traffic from within the VPC to this endpoint by default
/**
* Enable this if the provider has set a private DNS name for the VPC endpoint service
* @see https://docs.aws.amazon.com/ja_jp/vpc/latest/privatelink/manage-dns-names.html
*/
// privateDnsEnabled: true, // Enable this after the provider accepts
}
);
// -----------------------------------------------
// ↓ Implementation of resources (Lambda) to access the provider side
// -----------------------------------------------
/**
* Security Group for Lambda
*
* MEMO: Explicitly created to restrict connections to VPC Endpoint only. (If not explicitly created, allowAllOutbound is enabled by default)
*/
const lambdaSecurityGroup = new ec2.SecurityGroup(
this,
"LambdaSecurityGroup",
{
vpc,
allowAllOutbound: false,
}
);
/**
* Lambda function for testing PrivateLink connection
*/
new lambda_nodejs.NodejsFunction(this, "PrivateLinkTestLambda", {
entry: "handler.ts",
logGroup: new logs.LogGroup(this, "LogGroup", {
removalPolicy: cdk.RemovalPolicy.DESTROY, // Since this is a test resource, delete the log group when the stack is deleted
}),
vpc,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
securityGroups: [lambdaSecurityGroup],
environment: {
// Set the DNS name to access the VPC endpoint from the Lambda function as an environment variable
VPC_ENDPOINT_DNS_NAME: `https://${PRIVATE_DNS_NAME}`,
},
});
/**
* Allow access from lambdaFunction to VPC Endpoint on port 443
*/
vpcEndpoint.connections.allowFrom(lambdaSecurityGroup, ec2.Port.tcp(443));
}
}
```:::details Connectivity Verification Lambda Function Handler Implementation
```ts:handler.ts
import axios from "axios";
const VPC_ENDPOINT_DNS_NAME = process.env.VPC_ENDPOINT_DNS_NAME || "";
export const handler = async () => {
console.log(VPC_ENDPOINT_DNS_NAME);
try {
const response = await axios.get(VPC_ENDPOINT_DNS_NAME);
console.log("Response:", response.data);
return response.data;
} catch (error) {
console.error("Error:", error);
return { error: "Failed to fetch data" };
}
};
:::
Deploy the CDK with environment variables PROVIDER_VPC_ENDPOINT_SERVICE_NAME
set to the provider's VPC endpoint service name and PRIVATE_DNS_NAME
set to the private DNS name. This deployment creates a VPC endpoint and sends a connection request to the provider's VPC endpoint service.
Note that on the consumer side, the private DNS name enablement for the VPC endpoint must be disabled at this point because it should only be enabled after the provider has accepted the connection request.
Provider Side (Accepting the Connection Request)
When viewing the endpoint connection list of the provider's VPC endpoint service, you'll see the consumer's VPC endpoint added in an unaccepted state.
Accept the connection request.
If the connection request is rejected
When the provider rejects the connection request:
The consumer's VPC endpoint status becomes Rejected and becomes unusable.
If the provider's operator rejects the request by mistake, the consumer will need to create a VPC endpoint again and send a new connection request.
:::### Consumer Side (Verify Connection Using Private DNS Name)
About 10 seconds after the connection request was accepted, the consumer-side VPC endpoint status changed to Available.
Now I'd like to immediately verify connectivity, but at this point executing the Lambda function results in an error.
$ aws lambda invoke \
--function-name ${FUNCTION_NAME} response.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
$ cat response.json
{"error":"Failed to fetch data"}
This is because we need to enable the private DNS name for the VPC endpoint. We need to add privateDnsEnabled: true
and redeploy.
/**
* VPC Endpoint creation
*/
const vpcEndpoint = new ec2.InterfaceVpcEndpoint(this, "PrivateLinkEndpoint", {
vpc,
service: new ec2.InterfaceVpcEndpointService(
PROVIDER_VPC_ENDPOINT_SERVICE_NAME
),
subnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
securityGroups: [vpcEndpointSecurityGroup],
open: false,
privateDnsEnabled: true, // Changed to enabled
});
After deployment, looking at the VPC endpoint details screen confirms that the private DNS name specified by the provider is now available.
After waiting for the DNS propagation and executing the Lambda function again, we get a successful response. (If attempted before propagation completes, connection errors will persist)
$ aws lambda invoke \
--function-name ${FUNCTION_NAME} response.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
$ cat response.json
"Hello from ALB!"
This confirms that the consumer side can successfully connect to the VPC endpoint using the private DNS name.
Private DNS can only be enabled after the endpoint connection is accepted by the owner of com.amazonaws.vpce.<region>.<VPC endpoint ID>.
occurs
Troubleshooting### When enabling Private DNS on the provider side before acceptance, an error If you enable private DNS before the consumer-side VPC endpoint is accepted, the deployment will fail with the following error:
MainStack | 2 | 11:50:32 PM | UPDATE_FAILED | AWS::EC2::VPCEndpoint | PrivateLinkConsumer/PrivateLinkEndpoint (PrivateLinkConsumerPrivateLinkEndpoint8010210F) Resource handler returned message: "Private DNS can only be enabled after the endpoint connection is accepted by the owner of com.amazonaws.vpce.ap-northeast-1.vpce-svc-002a3e872c7846a9c. (Service: Ec2, Status Code: 400, Request ID: 8070ca16-b033-4b0a-95c9-019133f8ed9a) (SDK Attempt Count: 1)" (RequestToken: 5cb452ad-975f-0c6a-af71-0108687f55e4, HandlerErrorCode: GeneralServiceException)
Deploy with privateDnsEnabled
disabled first, and then after the provider side has accepted, enable privateDnsEnabled
and deploy again.
References