Goal: type ssh admin@lab.fullstacklab.site, complete MFA (email + PIN), and land on your server’s shell — all without exposing port 22 to the internet.
Stack: Docker container running cloudflared (connector), Cloudflare Zero Trust (Access + Tunnel), and a Windows client using cloudflared as an SSH proxy. Port 22 stays closed on your firewall.
At a glance
- No public port 22 — the SSH daemon only listens locally.
- Zero Trust policies enforce identity + MFA before the TCP session is even allowed.
- Client-side proxy (
cloudflared) makessshseamless. - Everything is reproducible with a tiny
docker-compose.yml.
1) Prerequisites — quick checks
| Requirement | How to verify |
|---|---|
| Domain in Cloudflare | In Cloudflare Dashboard, domain fullstacklab.site is active. |
| Docker on the server | docker --version prints a version |
| SSH service on host | sudo systemctl status ssh shows active (running) |
| Windows client has OpenSSH | ssh -V prints a version |
2) Configure Cloudflare Zero Trust (≈5 minutes)
Step 2.1 — Enable Zero Trust for your domain
- Open Cloudflare Zero Trust.
- Settings → Domains → Add a domain, pick
fullstacklab.siteand complete ownership checks if prompted.
Step 2.2 — Create an SSH application
- Access → Applications → Add an application
- Choose Self-hosted
- Fill in:
- Name: SSH to Server
- Subdomain:
lab - Domain:
fullstacklab.site - Type:
SSH - Service URL:
host.docker.internal:22(this is resolved by the connector on the server)
- Click Save.
Step 2.3 — Add an Access policy (MFA)
- While editing the application, go to Policies → Add policy.
- Action: Allow
- Include: Emails ending in → e.g.
@gmail.comor your exact email - Require: One-time PIN (or Any Access MFA)
- Add a second policy (optional hardening): Block → Everyone (placed after your Allow)
- Save.
Step 2.4 — Create a tunnel & get a connector token
- Access → Tunnels → Create a tunnel
- Name it
ssh-tunneland click Save. - Under Connectors, click Create token and copy the long token string (starts like
eyJhbGciOi...).
3) Server-side Docker (connector)
Create docker-compose.yml next to a local .env file:
version: '3.8'
services:
cloudflared-ssh:
image: cloudflare/cloudflared:latest
container_name: cloudflared-ssh
restart: unless-stopped
command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TOKEN}
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- TUNNEL_ORIGIN_CERT=/dev/null
privileged: true
cloudflared-tcp:
image: cloudflare/cloudflared:latest
container_name: cloudflared-access
restart: unless-stopped
command: access tcp --hostname lab.fullstackslab.site --url 0.0.0.0:2222
ports:
- "2222:2222"
environment:
- TUNNEL_TOKEN=${CLOUDFLARED_TOKEN}
- NO_AUTOMATIC_CERTIFICATES=true
network_mode: bridge
depends_on:
- cloudflared-ssh
Line-by-line explanation
services:— Start of your Compose service definitions.cloudflared-ssh:— The service name (and default network alias).image: cloudflare/cloudflared:latest— Official connector image; pulls the newest tag. Pin a version for stability in production (e.g.,2024.10.2).container_name: cloudflared-ssh— Explicit container name for easier log/ps commands.restart: unless-stopped— Auto-restart on failure or reboot.command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TOKEN}—tunnel— Use the Cloudflare Tunnel feature.--no-autoupdate— Disable self-update to avoid surprise restarts.run— Run the previously created tunnel in your account.--token ${CLOUDFLARED_TOKEN}— Pass the connector token from the.envfile (never hardcode in Compose).
extra_hosts: "host.docker.internal:host-gateway"— On Linux, this maps the special hostname
host.docker.internalto the host’s gateway IP so the container can reach the host’s SSH at22.environment: TUNNEL_ORIGIN_CERT=/dev/null— We’re using a token, not an origin cert, so this avoids mounting creds.privileged: true— Not strictly required for basic tunnels; remove if not needed. Keep least privilege where possible.
Bootstrap the container
# 1) Put your connector token into .env (same folder as docker-compose.yml)
echo "CLOUDFLARED_TOKEN=eyJhbGciOiYourLongToken..." > .env
# 2) Start the tunnel
docker compose up -d
# 3) Watch logs
docker logs -f cloudflared-ssh
Expected logs: lines like “Registered tunnel connection” and later, on SSH, “Forwarding to ssh://host.docker.internal:22”.
4) Windows client — install cloudflared
- Download from the official releases page: cloudflared-windows-amd64.exe.
- Rename to
cloudflared.exeand move toC:\Program Files\Cloudflare\cloudflared.exe. - Optionally add the folder to your PATH:
[Environment]::SetEnvironmentVariable( "Path", $env:Path + ";C:\Program Files\Cloudflare", "User" )Restart your terminal to apply.
5) Windows — SSH client configuration
Create or edit C:\Users\user\.ssh\config:
Host lab.fullstacklab.site
User admin
ProxyCommand "C:\Program Files\Cloudflare\cloudflared.exe" access ssh --hostname %h
Line-by-line explanation
Host lab.fullstacklab.site— Profile name; must match what you type afterssh.User admin— Default SSH username sent to the server.ProxyCommand "...\cloudflared.exe" access ssh --hostname %h— Instead of connecting directly to TCP/22 on the internet,
OpenSSH launchescloudflaredwhich authenticates via Cloudflare Access (MFA) and forwards the TCP stream through the tunnel.
If you added to PATH, you can shorten: ProxyCommand cloudflared access ssh --hostname %h
6) First connection (MFA flow)
ssh -v admin@lab.fullstacklab.site
sshspawnscloudflaredas a proxy.- Your browser opens to Cloudflare Access for lab.fullstacklab.site.
- Enter your email → receive a one-time PIN → submit.
- On success, the proxy connects to the tunnel and you land on
mocco@server:~$.
7) Verification checklist
| What | Command / Action | Expected output |
|---|---|---|
| Tunnel is up | docker logs cloudflared-ssh |
Registered tunnel connection |
| Web SSH prompt | Open https://lab.fullstacklab.site |
Cloudflare Access login (MFA) |
| CLI SSH | ssh admin@lab.fullstacklab.site |
Prompts MFA → shell |
| Forwarding path | docker logs -f cloudflared-ssh during login |
Forwarding to ssh://host.docker.internal:22 |
8) Troubleshooting
| Symptom | Fix |
|---|---|
CreateProcessW failed on Windows |
Use full quoted path in ProxyCommand; verify file exists at C:\Program Files\Cloudflare\cloudflared.exe. |
No such file or directory (ProxyCommand) |
Typos or missing quotes; confirm PATH or use absolute path. |
| PIN not received | Check spam; ensure email is allowed by policy; resend after a minute. |
connection refused |
On the server, verify sshd is running and listening on 127.0.0.1:22 or 0.0.0.0:22. The firewall can block WAN; tunnel doesn’t need WAN 22. |
| Timeouts during connect | Server must reach Cloudflare (egress 443). Check outbound firewall/proxy and DNS resolution. |
host.docker.internal not resolving |
Ensure the extra_hosts line is present as shown; restart the container. |
9) Optional: fewer MFA prompts
- Open the SSH application in Zero Trust.
- Set Session duration to e.g. 24 hours.
- After the first successful login, subsequent SSH within the window reuses the Access session cookie.
10) Security notes & hardening
- Rotate the connector token if it ever leaks. Store it only in
.envor a secrets manager. - Limit who can access — prefer specific emails or SSO groups over broad domains.
- Remove
privileged: trueif not required in your environment. - Pin the image version (e.g.,
cloudflare/cloudflared:2024.10.2) and plan periodic updates. - Audit logs in Zero Trust to see who connected and when.
11) Copy-ready snippets
Server: .env
CLOUDFLARED_TOKEN=eyJhbGciOiYourLongTokenFromCloudflare...
Server: docker-compose.yml
version: '3.8'
services:
cloudflared-ssh:
image: cloudflare/cloudflared:latest
container_name: cloudflared-ssh
restart: unless-stopped
command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TOKEN}
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- TUNNEL_ORIGIN_CERT=/dev/null
privileged: true
cloudflared-tcp:
image: cloudflare/cloudflared:latest
container_name: cloudflared-access
restart: unless-stopped
command: access tcp --hostname lab.fullstackslab.site --url 0.0.0.0:2222
ports:
- "2222:2222"
environment:
- TUNNEL_TOKEN=${CLOUDFLARED_TOKEN}
- NO_AUTOMATIC_CERTIFICATES=true
network_mode: bridge
depends_on:
- cloudflared-ssh
Windows client: %USERPROFILE%\.ssh\config
Host lab.fullstacklab.site
User admin
ProxyCommand "C:\Program Files\Cloudflare\cloudflared.exe" access ssh --hostname %h
Conclusion
You now have SSH access to lab.fullstacklab.site protected by Cloudflare Zero Trust — no open port 22, identity-aware access with MFA, and a simple Dockerized connector. If you want a quick health check, share a short excerpt of docker logs cloudflared-ssh after a login attempt and verify you see the forwarding lines.