Deploying Applications on EC2 with Cloudflare Tunnel
Quick Start
Provision and Deploy
# Get VPC and subnet
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" --query 'Vpcs[0].VpcId' --output text)
SUBNET_ID=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" "Name=map-public-ip-on-launch,Values=true" --query 'Subnets[0].SubnetId' --output text)
# Create security group
SG_ID=$(aws ec2 create-security-group --group-name app-sg --description "Security group for app deployment" --vpc-id $VPC_ID --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 22 --cidr 0.0.0.0/0
# Launch instance
AMI_ID=$(aws ec2 describe-images --owners amazon --filters "Name=name,Values=al2023-ami-2023*-x86_64" "Name=state,Values=available" --query 'sort_by(Images, &CreationDate)[-1].ImageId' --output text)
INSTANCE_ID=$(aws ec2 run-instances --image-id $AMI_ID --instance-type t3.small --key-name app-deploy-key --security-group-ids $SG_ID --subnet-id $SUBNET_ID --associate-public-ip-address --query 'Instances[0].InstanceId' --output text)
Prerequisites
- AWS CLI configured with appropriate permissions (EC2, VPC)
- Cloudflare account with a domain configured
- Docker-compatible application with Dockerfile
- Cloudflare Tunnel token from Zero Trust dashboard
Infrastructure Setup
1. VPC Resources
# Get default VPC
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" \
--query 'Vpcs[0].VpcId' --output text)
# Get public subnet
SUBNET_ID=$(aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=$VPC_ID" "Name=map-public-ip-on-launch,Values=true" \
--query 'Subnets[0].SubnetId' --output text)
2. Security Group
# Create security group - only SSH needed for management
# Cloudflare Tunnel handles all inbound traffic
SG_ID=$(aws ec2 create-security-group \
--group-name app-sg \
--description "Security group for app deployment" \
--vpc-id $VPC_ID \
--query 'GroupId' --output text)
# Allow SSH access (restrict to your IP in production)
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID \
--protocol tcp --port 22 --cidr 0.0.0.0/0
Key insight: With Cloudflare Tunnel, you don't need to open ports 80/443. The tunnel creates outbound connections to Cloudflare, eliminating inbound attack surface.
3. SSH Key Pair
# Generate local key
ssh-keygen -t ed25519 -f ~/.ssh/app-deploy-key -N "" -C "app-deploy"
# Import to AWS
aws ec2 import-key-pair \
--key-name app-deploy-key \
--public-key-material fileb://~/.ssh/app-deploy-key.pub
Important: Never delete SSH keys after creation - you'll be locked out of the instance.
4. Launch EC2 Instance
# Get latest Amazon Linux 2023 AMI
AMI_ID=$(aws ec2 describe-images --owners amazon \
--filters "Name=name,Values=al2023-ami-2023*-x86_64" "Name=state,Values=available" \
--query 'sort_by(Images, &CreationDate)[-1].ImageId' --output text)
# Launch instance
INSTANCE_ID=$(aws ec2 run-instances \
--image-id $AMI_ID \
--instance-type t3.small \
--key-name app-deploy-key \
--security-group-ids $SG_ID \
--subnet-id $SUBNET_ID \
--associate-public-ip-address \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=my-app}]' \
--query 'Instances[0].InstanceId' --output text)
# Wait for instance and get IP
aws ec2 wait instance-running --instance-ids $INSTANCE_ID
PUBLIC_IP=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
Instance Sizing
| Instance Type | Use Case |
|---------------|----------|
| t3.micro | Simple static sites, minimal APIs |
| t3.small | Standard web apps, Node.js/Python services |
| t3.medium | Apps with build steps, multiple containers |
Application Deployment
Install Docker
# Wait for SSH to be ready (30-60 seconds after instance running)
sleep 45
# Install Docker
ssh -i ~/.ssh/app-deploy-key ec2-user@$PUBLIC_IP \
"sudo dnf install -y docker git && \
sudo systemctl enable --now docker && \
sudo usermod -aG docker ec2-user"
Deploy Application
# Clone and build application
ssh -i ~/.ssh/app-deploy-key ec2-user@$PUBLIC_IP \
"git clone <REPO_URL> app && \
cd app && \
echo 'ENV_VAR=value' > .env"
# Build and run container
ssh -i ~/.ssh/app-deploy-key ec2-user@$PUBLIC_IP \
"cd app && \
sudo docker build -t myapp:latest . && \
sudo docker run -d --name myapp --restart unless-stopped -p 80:80 myapp:latest"
Production Dockerfile Pattern
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_KEY
ENV VITE_API_KEY=$VITE_API_KEY
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Reasoning: Multi-stage builds reduce image size and don't include build tools in production.
Cloudflare Tunnel Configuration
Install cloudflared
ssh -i ~/.ssh/app-deploy-key ec2-user@$PUBLIC_IP \
"curl -fsSL https://pkg.cloudflare.com/cloudflared.repo | \
sudo tee /etc/yum.repos.d/cloudflared.repo && \
sudo yum install -y cloudflared"
Install Tunnel as Service
# Use the token from Cloudflare Zero Trust dashboard
ssh -i ~/.ssh/app-deploy-key ec2-user@$PUBLIC_IP \
"sudo cloudflared service install <TUNNEL_TOKEN>"
Reasoning: Installing as a systemd service ensures the tunnel auto-starts on reboot and restarts on failure.
Verify Tunnel Connection
ssh -i ~/.ssh/app-deploy-key ec2-user@$PUBLIC_IP \
"sudo systemctl status cloudflared"
Look for: Registered tunnel connection messages (should see 4 connections for a healthy tunnel).
DNS Configuration
Cloudflare Dashboard Method
In Cloudflare Zero Trust dashboard:
- Navigate to Networks → Tunnels
- Select your tunnel → Public Hostname tab
- Add hostname:
- Subdomain:
app - Domain:
example.com - Type:
HTTP - URL:
localhost:80
- Subdomain:
This automatically creates a CNAME record pointing to <tunnel-id>.cfargotunnel.com.
API Method
# Create DNS record via API
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"type": "CNAME",
"name": "app",
"content": "<tunnel-id>.cfargotunnel.com",
"proxied": true
}'
Validation
Test Deployment
# Test via tunnel (may take 1-2 minutes for DNS propagation)
curl -sI https://app.example.com/
# Check container logs
ssh -i ~/.ssh/app-deploy-key ec2-user@$PUBLIC_IP \
"sudo docker logs myapp"
# Verify tunnel health
ssh -i ~/.ssh/app-deploy-key ec2-user@$PUBLIC_IP \
"sudo journalctl -u cloudflared -n 20"
Success Criteria
- HTTP 200 response from public URL
- No errors in container logs
- 4 registered tunnel connections
Outputs Documentation
Create an outputs file for future reference:
{
"app_url": "https://app.example.com",
"ec2_instance_id": "<instance-id>",
"ec2_public_ip": "<public-ip>",
"aws_region": "us-east-1",
"ssh_command": "ssh -i ~/.ssh/app-deploy-key ec2-user@<public-ip>",
"cloudflare_tunnel_id": "<tunnel-id>",
"container_name": "myapp"
}
Security Considerations
- Restrict SSH access - Use specific IP ranges instead of 0.0.0.0/0
- No public ports needed - Cloudflare Tunnel eliminates need for ports 80/443
- Rotate tunnel tokens - If compromised, regenerate in Cloudflare dashboard
- Use secrets management - Don't hardcode API keys in Dockerfiles; use build args or runtime env vars
- Enable Cloudflare Access - Add authentication layer for sensitive applications
Troubleshooting
| Issue | Diagnosis | Solution |
|-------|-----------|----------|
| DNS not resolving | dig @1.1.1.1 app.example.com | Wait for propagation or check CNAME record |
| Tunnel not connecting | Check systemctl status cloudflared | Verify token, check outbound connectivity on port 7844 |
| Container not starting | docker logs myapp | Check Dockerfile, environment variables |
| 502 Bad Gateway | Tunnel running but app not responding | Verify container is listening on correct port |