Cloudflare Zero Trust + SSH via Docker

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) makes ssh seamless.
  • 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

  1. Open Cloudflare Zero Trust.
  2. Settings → Domains → Add a domain, pick fullstacklab.site and complete ownership checks if prompted.

Step 2.2 — Create an SSH application

  1. Access → Applications → Add an application
  2. Choose Self-hosted
  3. 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)
  4. Click Save.

Step 2.3 — Add an Access policy (MFA)

  1. While editing the application, go to PoliciesAdd policy.
  2. Action: Allow
  3. Include: Emails ending in → e.g. @gmail.com or your exact email
  4. Require: One-time PIN (or Any Access MFA)
  5. Add a second policy (optional hardening): BlockEveryone (placed after your Allow)
  6. Save.

Step 2.4 — Create a tunnel & get a connector token

  1. Access → Tunnels → Create a tunnel
  2. Name it ssh-tunnel and click Save.
  3. 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 .env file (never hardcode in Compose).
  • extra_hosts: "host.docker.internal:host-gateway" — On Linux, this maps the special hostname
    host.docker.internal to the host’s gateway IP so the container can reach the host’s SSH at 22.
  • 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

  1. Download from the official releases page: cloudflared-windows-amd64.exe.
  2. Rename to cloudflared.exe and move to C:\Program Files\Cloudflare\cloudflared.exe.
  3. 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 after ssh.
  • 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 launches cloudflared which 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
  1. ssh spawns cloudflared as a proxy.
  2. Your browser opens to Cloudflare Access for lab.fullstacklab.site.
  3. Enter your email → receive a one-time PIN → submit.
  4. 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

  1. Open the SSH application in Zero Trust.
  2. Set Session duration to e.g. 24 hours.
  3. 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 .env or a secrets manager.
  • Limit who can access — prefer specific emails or SSO groups over broad domains.
  • Remove privileged: true if 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.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.