Skip to content

AWS EC2 Setup

This page covers the foundational AWS EC2 host setup that every App-name deployment runs on: launching an Ubuntu 26.04 LTS (or latest LTS) instance, configuring its security group, installing Docker and Compose, and preparing the deploy directories. Do this once per environment (test and production) before configuring the Cloudflare Tunnel.

Two environments

The shared setup uses exactly two tiers: test (/opt/app-name, test.<your-domain>) and production (/opt/app-name-prod, <your-domain>). Provision one EC2 instance per tier.

Prerequisites

  • AWS account with EC2 access
  • An SSH key pair (created below, or reuse an existing one)
  • A Cloudflare account with <your-domain> added to Cloudflare DNS (used later for the Cloudflare Tunnel)

Step 1 — Launch the instance

In the AWS Console, go to EC2 → Launch Instance and use the values below.

Setting Recommended value
Name app-name-test or app-name-prod
OS / AMI Ubuntu Server 26.04 LTS (or latest LTS)
Instance type t3.medium (test), t3.large (production)
Storage 30 GB GP3

Key pair

The key pair is used twice: for the initial SSH connection (before the tunnel exists) and as the second-layer SSH credential for GitHub Actions deployments. Ongoing admin SSH access does not use it — that runs over WARP (see Cloudflare Tunnel).

To create one during launch: choose Create new key pair, name it app-name-key, type RSA, format .pem (macOS/Linux) or .ppk (Windows/PuTTY), and download it. The private key is downloadable only once.

After download, move it into place and lock down its permissions:

mv ~/Downloads/app-name-key.pem ~/.ssh/
chmod 400 ~/.ssh/app-name-key.pem

Warning

Store the .pem file securely and never commit it to version control. You will need it for the initial connection and for the GitHub Actions deploy secret.

Step 2 — Configure the security group

The security group only needs an inbound SSH rule for initial bootstrap. Once the Cloudflare Tunnel is live, admin SSH runs over WARP and CI/CD runs over a service token — so there is no need to keep a public SSH port open long-term.

Reusable "Allow-SSH-developers-IP" rule

Rather than re-typing the bootstrap SSH rule for every new instance, maintain a single reusable inbound rule named Allow-SSH-developers-IP scoped to your developers' source IP (or a small IP allow-list / prefix list). Create it once, then attach it to each new instance's security group during bootstrap and detach it again after the tunnel is verified.

Port Protocol Source Purpose Lifetime
22 TCP Developer IP (Allow-SSH-developers-IP) SSH for initial setup Temporary — remove after tunnel works

To apply it: EC2 → Security Groups → select the instance's group → Inbound rules → Edit inbound rules → Add rule (TCP/22, source = your developer IP) → Save rules.

TODO

The exact form of the reusable Allow-SSH-developers-IP rule is still undecided: it can be a managed prefix list referenced from every security group, a shared security group attached alongside the per-instance group, or a copy-paste rule. Pick one approach and standardise on it across both environments.

Outbound rules

Allow outbound traffic so the host can pull packages and reach Cloudflare:

Port Protocol Destination Purpose
443 TCP 0.0.0.0/0 HTTPS package downloads, Cloudflare tunnel
80 TCP 0.0.0.0/0 HTTP package downloads
53 UDP 0.0.0.0/0 DNS resolution

Step 3 — Connect and update the system

With the bootstrap SSH rule attached, connect using the key from Step 1:

ssh -i ~/.ssh/app-name-key.pem ubuntu@<instance-public-ip>

Then update the OS and install base tooling:

sudo apt update
sudo apt upgrade -y
sudo apt install -y curl wget git ca-certificates

Optional — custom welcome banner

A per-environment MOTD makes it obvious which host you are on:

sudo tee /etc/update-motd.d/99-custom-welcome >/dev/null <<'EOF'
#!/bin/bash
echo ""
echo "==================================="
echo "   Welcome to App-name Test Server "
echo "==================================="
echo ""
EOF
sudo chmod +x /etc/update-motd.d/99-custom-welcome

Use App-name Production Server on the production host. Higher numbers run last, so a 99- prefix puts your banner after the system messages.

Step 4 — Install Docker and Compose

Remove any conflicting packages, add Docker's official repository, then install the engine and Compose plugin.

# Remove old packages (ignore "not installed" errors)
sudo apt-get remove -y docker docker-engine docker.io containerd runc

# Add Docker's GPG key and repository
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Enable the service and add your user to the docker group so you can run Docker without sudo:

sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker $USER
newgrp docker

Verify:

docker --version
docker compose version

Step 5 — Set up the deploy directory

CI/CD deploys into a fixed path per environment. Create it and hand ownership to the ubuntu user.

Environment Deploy path
Test /opt/app-name
Production /opt/app-name-prod
cd /opt
sudo mkdir -p app-name        # or app-name-prod on production
sudo chown $USER:$USER app-name
cd app-name

The deploy pipeline (see Pipeline Overview) ships only the files the host needs to run the stack — primarily docker-compose.yml and the nginx/ directory that fronts the containers (see Nginx Reverse Proxy).

File / directory Purpose
docker-compose.yml Container orchestration for the stack
nginx/ Reverse-proxy configuration rsynced by CI
.env Environment variables — see Secrets & Environment

Cloning manually (optional)

If you prefer to seed the directory by hand instead of letting CI populate it, generate an SSH deploy key on the host (ssh-keygen -t ed25519 -C "you@example.com"), add the public key to GitHub, verify with ssh -T git@github.com, then git clone --depth 1 git@github.com:<org>/app-name.git app-name. Once CI/CD owns deployments this key can be removed (see the cleanup section of Cloudflare Tunnel).

Verification checklist

Before moving on to the Cloudflare Tunnel, confirm:

  • [ ] EC2 instance running Ubuntu 26.04 LTS (or latest LTS)
  • [ ] Bootstrap SSH rule (Allow-SSH-developers-IP, port 22) attached for now
  • [ ] System updated (sudo apt update && sudo apt upgrade -y)
  • [ ] Docker running (docker --version)
  • [ ] Compose installed (docker compose version)
  • [ ] User in the docker group (groups $USER shows docker)
  • [ ] Deploy directory created (/opt/app-name or /opt/app-name-prod) and owned by ubuntu

Troubleshooting

Docker permission denied — the group membership did not take effect in your current shell:

sudo usermod -aG docker $USER
newgrp docker      # or log out and back in

Git clone authentication failed — verify the deploy key is added to GitHub and ssh -T git@github.com greets you by username. Re-check that you copied the entire ssh-ed25519 ... public key.

Instance not reachable on port 22 — confirm the Allow-SSH-developers-IP rule is attached and scoped to your current IP. This rule is only needed during bootstrap; afterwards SSH runs over Cloudflare Tunnel.


Next: Cloudflare Tunnel