Skip to main content

Zero Trust Security: Securing Web App on EC2 with Cloudflare Tunnel

· 13 min read
Abdulmalik
AppSec Engineer

Most internal web apps I have seen are deployed to the public internet. The assumption is usually that the app is secure enough, but the network layer is still exposed.

A better default is to put the app in a private subnet and access it through a tunnel. The options are site-to-site VPNs, bastion hosts, Direct Connect, PrivateLink, or tunneling. For most teams, Cloudflare Tunnel is the simplest and cheapest path.

The Challenge with Deploying Internal Applications

Cloudflare Zero Trust

Cloudflare Zero Trust

If you say we should have our internal apps not deployed to the public internet, how do we then handle it? You deploy to private subnets/networks. But the cons of deploying to private subnets or private network? Accessing the apps, which then brings us to various solutions:

  • Site-to-Site VPNs (OpenVPN, AWS VPN, WireGuard)
  • Setting up bastion Host
  • Direct Connect with AWS Direct Connect
  • Private Link with AWS PrivateLink
  • Tunneling

Following the mantra that I have also stuck with, "simplicity is preferable" so why not go for something simpler that works the same way - Cloudflare Tunneling.

AWS VPN and OpenVPN work, but Cloudflare Tunnel is free and requires no client-side VPN configuration for most users.

Prerequisites

  • Experience with OpenTofu/Terraform
  • Cloudflare Account with Your Domain Added Already
  • Your Container Repository to push your the container image built from the Dockerfile
  • Access to Google Workspace for SSO

Implementation overview

The full OpenTofu code is on GitHub.

StepWhat you buildWhy it matters
1cloudflared container imageConnects the private EC2 instance to Cloudflare's edge.
2VPC with public/private subnets and fck-natHosts the app in a private subnet without AWS NAT Gateway cost.
3EC2 instance in the private subnetRuns the internal application behind the tunnel.
4Google Workspace IdP + access policyAuthenticates users and enforces device posture.
5Cloudflare Tunnel + DNS recordPublishes the app behind Zero Trust without exposing the origin.

1. Building our cloudflared container image

First, we'll build the cloudflared container image, here is the Dockerfile and entrypoint.sh file for that.

So you have to build the container using the below command, depending on the container image, your build syntax might change, but ensure you build for platform linux/arm64 because the instance we are dealing with is linux/arm64 architecture.

docker buildx build --platform linux/arm64 -t $YOUR_CONTAINER_REGISTRY/$YOUR_CONTAINER_REPOSITORY:$IMAGE_TAG -f Dockerfile . --push

Here is the Dockerfile file configurations

Dockerfile
# ==============================================================================
# Download cloudflared
# ==============================================================================
FROM debian:bookworm-slim as build

# Install dependencies
RUN apt-get update && \
apt-get install -y curl

# Download cloudflared
RUN curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared && \
chmod +x cloudflared && \
mv cloudflared /usr/local/bin/

# ==============================================================================
# Run from alpine
# ==============================================================================
FROM alpine:3.20

# Install dependencies
RUN apk update && apk add --no-cache aws-cli

# Get cloudflared from build
COPY --from=build /usr/local/bin/cloudflared /usr/local/bin/cloudflared

# Copy script
WORKDIR /etc/cloudflared
COPY ./entrypoint.sh /etc/cloudflared/entrypoint.sh
RUN chmod +x /etc/cloudflared/entrypoint.sh

# Run script
ENTRYPOINT ["/etc/cloudflared/entrypoint.sh"]
entrypoint.sh
#! /bin/sh

set -ueo pipefail

# ==============================================================================
# CONFIG
# ==============================================================================
#
# STATIC
#
PATH_CLOUDFLARED="/etc/cloudflared"
PATH_CREDENTIALS="${PATH_CLOUDFLARED}/credentials.json"
PATH_CONFIG="${PATH_CLOUDFLARED}/config.yml"

#
# ENV VARS
#
VAR_ORIGIN_URL=${ORIGIN_URL}
VAR_TUNNEL_UUID=${TUNNEL_UUID}

#
# SECRETS
#
VAR_TUNNEL_CREDENTIALS=${TUNNEL_CREDENTIALS}


# ==============================================================================
# PREPARE CLOUDFLARED CONFIG
# ==============================================================================
#
# Create folder
#
mkdir -p ${PATH_CLOUDFLARED}

#
# Fetch secrets
#
echo "[*] Fetching Cloudflared Tunnel: credentials JSON..."
echo "$VAR_TUNNEL_CREDENTIALS" > $PATH_CREDENTIALS

#
# Create config file
#
echo -e "tunnel: ${VAR_TUNNEL_UUID}
credentials-file: ${PATH_CREDENTIALS}
url: ${VAR_ORIGIN_URL}
no-autoupdate: true" > $PATH_CONFIG


# ==============================================================================
# RUN TUNNEL
# ==============================================================================

# Run tunnel
echo "[*] Starting tunnel..."
cloudflared tunnel --config ${PATH_CONFIG} run ${VAR_TUNNEL_UUID}

2. VPC and Network Infrastructure

First, we'll set up our VPC with public and private subnets:

vpc.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = var.vpc_enable_dns_hostnames
tags = merge(
var.tags,
{
Name = "${var.prefix}_vpc"
}
)
}

# Public and Private Subnets
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
}

resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidr
availability_zone = "us-east-1a"
}

Then we implement the fck-nats the alternative to AWS NAT Gateway, instead of using the expensive AWS NAT Gateway, we implement a cost-effective alternative using fck-nat

fck-nats.tf
module "fck-nat" {
source = "RaJiska/fck-nat/aws"
version = "1.3.0"
name = "fck-nat-${var.env}"
vpc_id = aws_vpc.main.id
subnet_id = aws_subnet.public.id
instance_type = "t4g.nano"
ha_mode = true
}

3. EC2 Instance Configuration

Our EC2 instance runs in the private subnet:

instance.tf
resource "aws_instance" "ec2" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = var.instance_type
subnet_id = aws_subnet.private.id
vpc_security_group_ids = [aws_security_group.private_sg.id]
iam_instance_profile = aws_iam_instance_profile.instance_profile.name
user_data = local.instance_values

metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
}

4. Identity and Access Management

Here is where we define how to access the app beyound whatever AuthZ and Auth you might have defined or designed for your application, i see this as an additional security leverage.

You get the chance to use IdP, SAML, OpenID Connect or One-time PIN, but in this guide, we would be using Google Workspace as out IdP, here is a guide to help you set it up on the cloudflare zero trust console.

Cloudflare Zero Trust Access

Setting Warp Client Posture Check

If you are done with the setup, you will be left with google_oauth_client_id and google_oauth_client_secret credentials.

And as you know, you can't hardcode those values in your IaC code, hence the need to store it as a SecureString in AWS Parameter store or whatever alternative of AWS Parameter Store that you have on the Cloud Infra you are using.

Wrote a piece on how to handle secrets in IaC, you can check it out too.

Google Workspace Integration

cloudflared.tf
data "aws_ssm_parameter" "google_oauth_client_id" {
name = "/internalwebapp/google_oauth_client_id"
}

data "aws_ssm_parameter" "google_oauth_client_secret" {
name = "/internalwebapp/google_oauth_client_secret"
}

resource "cloudflare_zero_trust_access_identity_provider" "google_workspace" {
account_id = var.cloudflare_account_id
name = "Google Workspace IdP"
type = "google"
config {
client_id = data.aws_ssm_parameter.google_oauth_client_id.value
client_secret = data.aws_ssm_parameter.google_oauth_client_secret.value
apps_domain = var.google_workspace_domain
}
}

Trust Access App and Policy Configuration

Here is where we create the trust access application and the policy for it

cloudflared.tf
resource "cloudflare_zero_trust_access_application" "app" {
account_id = var.cloudflare_account_id
name = "internalwebapp"
domain = local.environment.domain

allowed_idps = [cloudflare_zero_trust_access_identity_provider.google_workspace.id]

type = "self_hosted"
session_duration = "24h"
app_launcher_visible = true
app_launcher_logo_url = var.cloudflare_app_logo
}

resource "cloudflare_zero_trust_access_policy" "policy" {
account_id = var.cloudflare_account_id
application_id = cloudflare_zero_trust_access_application.app.id

name = "internalwebapp-filter"
precedence = "1"
decision = "allow"
purpose_justification_required = true

include {
login_method = [cloudflare_zero_trust_access_identity_provider.google_workspace.id]
}

require {
gsuite {
identity_provider_id = cloudflare_zero_trust_access_identity_provider.google_workspace.id
}
group = local.environment.cloudflare_config.allowed_groups
}
}

The group local.environment.cloudflare_config.allowed_groups here is where we are adding the warp client requirement, i couldnt find the OpenTofu config for the cloudflare provider that allowed adding warp client as a requirement, so i had to create the access group via the console and add it as a group here.

To do the same thing, you need to enable the warp client manually via the console, on your Cloudflare One console,

Lets enable warp client first, head over to the settings section from the sidebar and click on Warp Client

Cloudflare One Warp Client

Cloudflare One Warp Client

Scroll down to the Device posture section, you will see warp client check settings, click on the Add new button

Cloudflare One Warp Client Settings

Cloudflare One Warp Client Settings

Select Warp from all the options and save it

Setting Warp Client Posture Check

Setting Warp Client Posture Check

Setting Warp Client Posture Check

Setting Warp Client Posture Check

After that, you can head over to the Access section on the sidebar and dropdown its option you will see Access groups, click on it and create a group

Creating Access Groups on Cloudflare One

Creating Access Groups on Cloudflare One

Add the group name and define the group criteria as inclusion of warp client, after the creation, copy the group ID and replace it as a group ID value in the IaC code.

Copy Access Groups ID on Cloudflare One

Creating Access Groups on Cloudflare One

5. Cloudflare Tunnel Setup

Configure the tunnel and DNS Setup

cloudflared.tf
resource "cloudflare_zero_trust_tunnel_cloudflared" "tunnel" {
account_id = var.cloudflare_account_id
name = "tunnel"
secret = random_password.tunnel_secret.result
}

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "config" {
account_id = var.cloudflare_account_id
tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.tunnel.id

config {
warp_routing {
enabled = true
}
ingress_rule {
service = local.environment.origin_url
}
}
}

here is how your cloudflare tunnel should look if health

Cloudflare Tunnel

Cloudflare Tunnel

cloudflared.tf
resource "cloudflare_record" "dns" {
zone_id = var.cloudflare_zone_id
name = local.environment.domain
value = cloudflare_zero_trust_tunnel_cloudflared.tunnel.cname
type = "CNAME"
proxied = true
ttl = 1
}

And lastly before you run tofu plan && tofu apply --auto-approve you need to create cloudflare api token for the cloudflared provider in your provider.tf

Cloudflare API Token Creation

Cloudflare API Token Creation

provider.tf
provider "cloudflare" {
api_token = "YOUR_CLOUDFLARE_TOKEN"
}

You can streamline your permission based on your usecase also, this is just a snippet to help you see what it looks like

Cloudflare API Token Permission

Cloudflare API Token Permission

At the end of the day, here is what your final webapp should look like if you have warp turned on your devices.

Enabled Warp Client on Your Devices

Enabled Warp Client on Your Devices

Final Look of the Internal Web App with Cloudflare Zero Trust

Final Look of the Internal Web App with Cloudflare Zero Trust

And since the use of warp client is made a requirement, if you dont have it turned on or there is other required policy that isnt being met, you will encounter a screen like this

Final Look of the Internal Web App with Cloudflare Zero Trust Without Meeting The Policy Requirements

Final Look of the Internal Web App with Cloudflare Zero Trust Without Meeting The Policy Requirements

Completion criterion

The setup is complete when:

  1. The EC2 instance has no public IP and lives in a private subnet.
  2. The cloudflared container is running and the tunnel shows healthy in the Cloudflare dashboard.
  3. A DNS record points the internal domain to the Cloudflare Tunnel.
  4. The Access policy requires Google Workspace authentication and the WARP client posture.
  5. With WARP turned on and the user in the allowed group, the app loads.
  6. With WARP off or the user outside the allowed group, the access-denied page is shown.

Conclusion

By leveraging Cloudflare Zero Trust and Tunnels, we've created a secure, cost-effective way to deploy internal applications. The solution provides enterprise-grade security without the complexity of traditional VPN setups.

Till next time, Peace be on you 🤞🏽

References


Comments