Implementing HTTPS redirection in Elastic Beanstalk via CodePipeline and Lambda

In a previous post I explained how SSL offloading was implemented for this blog. In that post I defined SSL offloading as an approach to handling secure Web traffic in which the computational burden of processing encrypted requests is offloaded to a specific component within an application’s environment. In the case of this blog, which runs on Elastic Beanstalk (EB), that component is the load balancer, which has a listener checking for connection requests that use the protocol HTTPS and the port 443 (HTTPS/443).

Once the ability to service HTTPS requests has been established, an additional behavior that may be considered desirable is for HTTP requests to be redirected automatically to HTTPS. Unfortunately this behavior doesn’t come out of the box with EB, where the HTTP listener forwards requests to the environment’s target group by default rather than redirecting them to the HTTPS listener. This post will explain how I overcame this limitation by modifying the HTTP listener’s configuration via a purpose-built Lambda function that is invoked by CodePipeline during the application’s deployment process. The post presupposes that a pipeline has been established for deploying an application to EB.

Let’s begin by creating the Lambda function that will modify the HTTP listener’s configuration. JavaScript will be used as the language for the function implementation.

Creating the Lambda function

export const handler = async (event, context) => {
  const codePipelineJob = event['CodePipeline.job'];
  const codePipelineJobId = codePipelineJob.id;
  const environmentName = codePipelineJob.data?.actionConfiguration?.configuration?.UserParameters;
  const {awsRequestId} = context;
  // ...
};

The first thing we’ll need to do when creating the Lambda function is to extract some information from the event and context objects that are passed into the function by default.

From the event object we’ll need the ID of the CodePipeline job as well as the name of the EB environment that is being deployed to, which in this case is stored in user parameters that are passed from the pipeline to the function. The job ID will be needed when reporting the outcome of the Lambda function to CodePipeline. The environment name will be needed when looking up the name of the load balancer.

From the context object we’ll need the AWS request ID—this will be needed when reporting a failure of the Lambda function to CodePipeline.

We’ll also need to instantiate the AWS clients that will be used by the Lambda function.

export const handler = async (event, context) => {
  // ...
  const elasticBeanstalkClient = new ElasticBeanstalkClient({ region: REGION });
  const elasticLoadBalancingV2Client = new ElasticLoadBalancingV2Client({ region: REGION });
  const codePipelineClient = new CodePipelineClient({ region: REGION });
  // ...
};

As you can see there are three of these:

  • ElasticBeanstalkClient – will be used for interfacing with the load balancer
  • ElasticLoadBalancingV2Client – will be used for interfacing with the load-balancer listener
  • CodePipelineClient – will be used for interfacing with CodePipeline

Now let’s begin our function implementation proper. The first step is to get the name of the load balancer.

Getting the name of the load balancer

const environmentResources = await elasticBeanstalkClient.send(
  new DescribeEnvironmentResourcesCommand({ EnvironmentName: environmentName }));
const loadBalancerNames = environmentResources.EnvironmentResources.LoadBalancers.map(loadBalancer => {
  return loadBalancer.Name;
});
if (!loadBalancerNames.length) {
  throw new Error("No load balancer found for environment: " + environmentName);
}
const loadBalancerName = loadBalancerNames[0];

To get the name of the load balancer we use ElasticBeanstalkClient to send a DescribeEnvironmentResourcesCommand to EB, passing the command the environment name we obtained from the Lambda function’s event object. We then interrogate the information that comes back to obtain the name of the load balancer. If there is no load-balancer name we throw an error; if there is we store it in a variable (loadBalancerName).

Now that we know the name of the load balancer, we should be able to use it to get the ARN of the load-balancer listener.

Getting the ARN of the load-balancer listener

const listenersData = await elasticLoadBalancingV2Client.send(new DescribeListenersCommand({ LoadBalancerArn: loadBalancerName }));
const listener = listenersData.Listeners.find(l => l.Port === 80);
if (!listener) {
  throw new Error("No listener found on port 80.");
}
const listenerArn = listener.ListenerArn;

To get the ARN of the load-balancer listener we use ElasticLoadBalancingV2Client to send a DescribeListenersCommand to the load balancer, passing the command the name of the load balancer. We then interrogate the information that comes back to obtain the relevant listener, in this case the one listening on port 80. If there is no listener we throw an error; if there is we store its ARN in a variable (listenerArn).

Now that we know the ARN of the listener, we should be able to modify the configuration of the listener to suit our needs.

Modifying the load-balancer listener

await elasticLoadBalancingV2Client.send(new ModifyListenerCommand({
  ListenerArn: listenerArn,
  DefaultActions: [
    {
      Type: "redirect",
      RedirectConfig: {
        Protocol: "HTTPS",
        Port: "443",
        StatusCode: "HTTP_301"
      }
    }
  ]
}));

To modify the load-balancer listener we reuse ElasticLoadBalancingV2Client to send a ModifyListenerCommand to the load balancer, passing the command the ARN of the listener. We also pass the command a redirect action along with its relevant configuration—in this case we want requests to be redirected to HTTPS/443 with an HTTP status code of 301 (Moved Permanently).

Now that the listener’s configuration has been modified, we should be able to complete our function implementation by reporting the function’s outcome to CodePipeline.

Reporting the Lambda function’s outcome to CodePipeline

export const handler = async (event, context) => {
  // ...
  try {
    // get load balancer name (could throw error)
    // get listener ARN (could throw error)
    // modify listener
    return await codePipelineClient.send(new PutJobSuccessResultCommand({ jobId: codePipelineJobId }));
  } catch (error) {
    return await codePipelineClient.send(new PutJobFailureResultCommand({
      jobId,
      failureDetails: {
        type: "JobFailed",
        message: "Job failed",
        externalExecutionId: awsRequestId
      }
    }));
  }
};

Given that our Lambda function is being invoked from a CodePipeline job, we have to report the function’s outcome to the job, otherwise the job will be left hanging. To report the function’s outcome to the job we use CodePipelineClient to send the relevant command based on whether the function was successful. If the function was successful, we send a PutJobSuccessResultCommand, passing the command the job ID; if unsuccessful, we send a PutJobFailureResultCommand, passing the command the job ID along with details of the failure. The function implementation as a whole is wrapped in a try-catch statement to catch any exceptions that result from any of the function’s internal workings.

Now that our Lambda function has been created all that remains is to update the CodePipeline job to invoke the function at the appropriate time, which in this case is immediately after the deployment to EB when the environment is ready.

Updating the CodePipeline job to invoke the Lambda function

To update the CodePipeline job to invoke the Lambda function, first go to the pipeline-detail page and click “Edit.”

Find the pipeline stage that deploys the code to Elastic Beanstalk and immediately beneath the panel for this stage click “Add stage.”

Enter a name for the new stage and click “Add stage.”

From the panel for the newly added stage click “Add action group.”

In the “Edit action” dialog enter the following information:

  1. Action name
  2. Action provider (select AWS Lambda from the Invoke option group)
  3. Function name
  4. User parameters (enter the name of the EB environment being deployed to)

From the bottom-right of the dialog click “Done.”

From the “Editing: <pipeline_name>” page click “Save.”

Execute the pipeline. The newly added stage should run successfully.

Finally visit the deployed application. A request for http://<your_domain> should be redirected automatically to https://<your_domain>. Try it with this blog’s homepage if you like: A request for http://www.davidsmithweb.com should be redirected automatically to https://www.davidsmithweb.com. The redirect may be reflected in your browser’s dev tools Network tab.

Conclusion

In this post I described a way to modify an EB load balancer listener’s configuration to redirect HTTP requests to HTTPS via the invocation of a Lambda function from a CodePipeline job. Given that redirection of this sort is a universally desirable behavior, implementing the behavior automatically in this way may be considered preferable to implementing it manually, for example as an item on a checklist of post-deployment steps. One drawback of the approach I described is that the CodePipeline action that invokes the Lambda function currently needs to know directly about the EB environment. This is an implementation-detail wrinkle I hope to iron out as I continue to refine this blog’s deployment process.