4 min read · Ingress

Cloudflare Access

SSH through Access - interactive, CI, and short-lived certificates.

Reaching a host that has no open port 22, over the tunnel from Cloudflare Tunnel.

Interactive

~/.ssh/config:

Host ssh.example.com
    ProxyCommand cloudflared access ssh --hostname %h
    User <user>
    IdentityFile ~/.ssh/prod_server
cloudflared access login ssh.example.com
ssh ssh.example.com

Non-interactive (CI)

A service token replaces the browser login.

Host ssh.example.com
    HostName ssh.example.com
    User <user>
    IdentityFile ~/.ssh/prod_server
    ProxyCommand cloudflared access ssh --hostname %h --id ${CF_ACCESS_CLIENT_ID} --secret ${CF_ACCESS_CLIENT_SECRET}
    StrictHostKeyChecking no
    UserKnownHostsFile=/dev/null

In a workflow the same two values come from repository secrets:

ProxyCommand cloudflared access ssh --hostname %h --id $ --secret $

Create the token from the API:

curl --request POST 'https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/access/service_tokens' \
  --header 'Authorization: Bearer <API_TOKEN>' \
  --header 'Content-Type: application/json' \
  --data-raw '{ "name": "droplet-service-token", "duration": "forever" }'

duration: forever never expires, so it has to be rotated by hand. Prefer a fixed duration where the deploy cadence allows it.

Short-lived certificates

Removes long-lived keys from the host entirely: Cloudflare signs a certificate per session, and the host trusts the CA rather than individual keys.

Generate the SSH CA under Zero Trust -> Access controls -> Service credentials -> SSH, then on the host write the CA public key to /etc/ssh/cloudflare_ca.pub:

sudo mkdir -p /etc/ssh
sudo nano /etc/ssh/cloudflare_ca.pub
# paste the full ssh-ed25519 CA key from the dashboard

Add to /etc/ssh/sshd_config:

TrustedUserCAKeys /etc/ssh/cloudflare_ca.pub
PubkeyAuthentication yes
sudo systemctl restart ssh
sudo grep -E "TrustedUserCAKeys|PubkeyAuthentication" /etc/ssh/sshd_config

Disable password login

sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak

sudo sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config

sudo systemctl restart ssh

Confirm key login works in a second terminal before closing the first.

Deployment secrets

SERVER_HOSTNAME            # public IP, or `curl ifconfig.me` on the host
SERVER_USER                # `whoami` on the host
SSH_PRIVATE_KEY            # generated below
CF_SERVICE_TOKEN_ID
CF_SERVICE_TOKEN_SECRET
ssh-keygen -t ed25519 -C "github-actions"
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
cat ~/.ssh/id_ed25519          # paste into the SSH_PRIVATE_KEY secret

Use a dedicated key for CI, never a personal one, so it can be revoked on its own.

Copying keys to a host

ssh-copy-id -i ~/.ssh/prod_server.pub <user>@<VM_IP>
ssh -i ~/.ssh/prod_server <user>@<VM_IP>

scp -i ~/.ssh/prod_server setup.py root@<SERVER_IP>:/root/setup.py