Remote Access Integration Guide

Technology: remote-access · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/remote-access

Insight:

Every tool here answers one question — how do I reach a machine that isn't in front of me — and the good answers all share a shape: identity over network position. SSH is the deep one: an Ed25519 keypair, a ~/.ssh/config block, and ProxyJump mean exactly one box on your estate has a public port, and -L/-R/-D carry any TCP stream through that single encrypted channel — so a private Postgres becomes localhost:5432 without widening one allowlist. Modern OpenSSH does more of the work for you than the folklore suggests: ssh-keygen has defaulted to Ed25519 since 9.5, DSA is gone as of 10.0, key agreement is post-quantum hybrid by default, and sshd ships its own brute-force penalty box. The one default that still bites: sshd accepts passwords until you turn them off.

██████╗ ███████╗███╗   ███╗ ██████╗ ████████╗███████╗     █████╗  ██████╗ ██████╗███████╗███████╗███████╗
██╔══██╗██╔════╝████╗ ████║██╔═══██╗╚══██╔══╝██╔════╝    ██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
██████╔╝█████╗  ██╔████╔██║██║   ██║   ██║   █████╗      ███████║██║     ██║     █████╗  ███████╗███████╗
██╔══██╗██╔══╝  ██║╚██╔╝██║██║   ██║   ██║   ██╔══╝      ██╔══██║██║     ██║     ██╔══╝  ╚════██║╚════██║
██║  ██║███████╗██║ ╚═╝ ██║╚██████╔╝   ██║   ███████╗    ██║  ██║╚██████╗╚██████╗███████╗███████║███████║
╚═╝  ╚═╝╚══════╝╚═╝     ╚═╝ ╚═════╝    ╚═╝   ╚══════╝    ╚═╝  ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝

Remote Access Integration Guide

Focus: securely reaching a machine or service that isn't directly exposed — a box behind a firewall, a localhost dev server Daraja needs to call, a Raspberry Pi on the office LAN, an Ubuntu distro inside WSL. Deep on SSH (keys, ~/.ssh/config, bastions, agent, forwarding, hardening), then tunnels, mesh VPN, and remote dev. Grounded in the OpenSSH man pages, ngrok, Cloudflare and Tailscale docs; reviewed 2026-08-23 against OpenSSH 10.5p1.

Table of contents

  1. Overview & decision tree
  2. SSH keys
  3. The SSH agent (and why forwarding is dangerous)
  4. ~/.ssh/config — host blocks
  5. Bastions & ProxyJump
  6. Port forwarding: -L, -R, -D
  7. Hardening sshd
  8. SSH and WSL Ubuntu
  9. Public tunnels (ngrok, Cloudflare Tunnel)
  10. VPN & zero-trust (Tailscale, WireGuard, Access)
  11. RDP / VNC
  12. Remote development
  13. Security checklist
  14. Troubleshooting
  15. codeAmani notes

Official documentation

Source URL What it covers
OpenSSH ssh manual https://www.man7.org/linux/man-pages/man1/ssh.1.html -L/-R/-D forwarding, -J jump host, -A, -N, -W, escape sequences
OpenSSH ssh_config https://www.man7.org/linux/man-pages/man5/ssh_config.5.html ~/.ssh/config, ProxyJump, IdentityFile, ControlMaster
OpenSSH sshd_config https://www.man7.org/linux/man-pages/man5/sshd_config.5.html Server hardening, PerSourcePenalties, AllowUsers, Match
OpenSSH ssh-keygen https://www.man7.org/linux/man-pages/man1/ssh-keygen.1.html Key generation, fingerprints, certificates, -R
OpenSSH release notes https://www.openssh.org/releasenotes.html What changed per release — Ed25519 default, DSA removal, PQ key exchange
ngrok getting started https://ngrok.com/docs/getting-started/ ngrok http, authtoken, auto-HTTPS, inspector
ngrok free plan limits https://ngrok.com/docs/pricing-limits/free-plan-limits/ Free static dev domain, endpoint cap, browser interstitial
Cloudflare Tunnel https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/ cloudflared tunnel create/route/run, outbound-only model
Tailscale CLI https://tailscale.com/kb/1080/cli up/set/status/ssh/funnel, exit nodes
Tailscale SSH https://tailscale.com/kb/1193/tailscale-ssh tailscale set --ssh, SSH ACL grants, check mode
What is Tailscale https://tailscale.com/kb/1151/what-is-tailscale WireGuard mesh, zero-trust, MagicDNS, ACLs
WSL networking https://learn.microsoft.com/windows/wsl/networking NAT vs mirrored mode, netsh portproxy, Hyper-V firewall

1. Overview & decision tree

"Remote access" is one verb — reach a thing that's elsewhere — answered by a handful of tools that trade off predictably:

flowchart TD
    Q{"What do you need<br/>to reach?"}
    Q -->|"a shell on a routable box"| SSH["SSH + ~/.ssh/config<br/>ProxyJump through one bastion"]
    Q -->|"the internet must hit<br/>my localhost"| TUN["ngrok / cloudflared<br/>public HTTPS front door"]
    Q -->|"many services,<br/>whole team, no open ports"| MESH["Tailscale / WireGuard<br/>identity-gated mesh"]
    Q -->|"a graphical desktop"| GUI["RDP/VNC<br/>tunnelled, never raw"]
    SSH --> FWD["-L / -R / -D<br/>carry any TCP stream"]
    classDef accent fill:#0891B2,color:#fff,stroke:#22D3EE
    classDef muted fill:#1e293b,color:#e2e8f0,stroke:#334155
    class SSH,TUN,MESH accent
    class GUI,FWD muted

Security is the constant: keys not passwords, least privilege, MFA, and an audit trail.

What changed in OpenSSH recently

Folklore about SSH ages badly. The current baseline (OpenSSH 10.5p1, released 2026-08-11):

Since Change What it means for you
8.8 ssh-rsa (RSA/SHA-1 signatures) disabled by default Ancient servers may reject your RSA key — regenerate as Ed25519 rather than re-enabling SHA-1
9.5 ssh-keygen generates Ed25519 by default ssh-keygen with no flags is already the right answer; -t ed25519 is documentation, not necessity
9.8 PerSourcePenalties — sshd's built-in penalty box The server already throttles brute-forcers before you install fail2ban
9.9 → 10.0 Hybrid post-quantum key agreement mlkem768x25519-sha256 is the default Harvest-now-decrypt-later is covered on both ends running ≥10.0; no config needed
10.0 DSA removed entirely ssh-dss keys are dead. If a device still needs DSA, it needs replacing, not a config exception

2. SSH keys

Generate

The private key never leaves your machine; only the .pub half is ever copied anywhere.

# Ed25519 — small, fast, and the ssh-keygen default since OpenSSH 9.5.
# -C is a free-text comment (shows up in authorized_keys — make it identify the key).
# -f names the file so you can keep per-purpose keys instead of one id_ed25519 for everything.
ssh-keygen -t ed25519 -C "barnabas@codeamani-laptop" -f ~/.ssh/id_ed25519

# Same, with a passphrase-hardened private key: -a sets KDF rounds (higher = slower to
# brute-force if the file is stolen). 100 is a common, comfortable value.
ssh-keygen -t ed25519 -a 100 -C "barnabas@codeamani-laptop" -f ~/.ssh/id_ed25519

# Only if a legacy appliance genuinely can't do Ed25519:
ssh-keygen -t rsa -b 4096 -C "legacy-appliance-only"

Always set a passphrase. An unprotected private key is a bearer token sitting in a file — anyone who copies it is you. The passphrase is what makes a stolen laptop a nuisance instead of a breach, and the agent (§3) means you type it once per boot.

Use one key per device, not one key per human. Losing a laptop should mean deleting one line from authorized_keys, not rotating every server you own.

Install the public half

# Easiest — needs an existing way in (password auth, or another key already installed)
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.example.com

# Manual equivalent when ssh-copy-id isn't available (permissions matter — see below)
cat ~/.ssh/id_ed25519.pub | ssh user@server.example.com \
  'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'

# Verify before you disable password auth — keep the working session open!
ssh -i ~/.ssh/id_ed25519 user@server.example.com

Permissions are enforced, not advisory. ~/.ssh must be 700, private keys 600, and authorized_keys 600. If the mode is looser, ssh refuses the key with UNPROTECTED PRIVATE KEY FILE and sshd silently ignores authorized_keys. This is the #1 cause of "my key just doesn't work" — and the #1 reason not to keep keys on a Windows mount inside WSL (§8).

Host key verification (the direction people forget)

Keys prove you to the server. The host key proves the server to you — it's what stops an on-path attacker from impersonating your bastion.

# First connect is trust-on-first-use: you're shown a fingerprint and asked to accept.
# Compare it out-of-band against the server's own fingerprint before typing yes:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub    # run on the server

# Pre-seed known_hosts for CI / scripted access instead of disabling checking
ssh-keyscan -t ed25519 server.example.com >> ~/.ssh/known_hosts

# Host key changed (rebuild, re-image, new VM at the same address)? Remove the stale entry:
ssh-keygen -R server.example.com

StrictHostKeyChecking accept-new auto-accepts a first fingerprint but still refuses a changed one — a reasonable middle ground for ephemeral infra. Never StrictHostKeyChecking no: that accepts changed keys too, which is precisely the attack it exists to catch.


3. The SSH agent (and why forwarding is dangerous)

The agent holds your decrypted private key in memory so you type the passphrase once instead of on every connection.

eval "$(ssh-agent -s)"          # start one (most desktops already run it)
ssh-add ~/.ssh/id_ed25519       # unlock the key into the agent
ssh-add -l                      # list loaded keys
ssh-add -t 8h ~/.ssh/id_ed25519 # auto-expire after 8 hours
ssh-add -c ~/.ssh/id_ed25519    # require confirmation on EVERY use of this key
ssh-add -D                      # drop all keys (do this when you step away)

Set AddKeysToAgent yes in ~/.ssh/config and the key loads on first use automatically. On macOS add UseKeychain yes to persist the passphrase in the Keychain. On Windows, the agent is a service: Start-Service ssh-agent; Set-Service ssh-agent -StartupType Automatic.

Agent forwarding: what it actually risks

ForwardAgent yes / ssh -A exposes your local agent's socket on the remote host so you can hop onward using your local keys. The ssh(1) man page is blunt about the consequence:

Users with the ability to bypass file permissions on the remote host (for the agent's Unix-domain socket) can access the local agent through the forwarded connection. An attacker cannot obtain key material from the agent, however they can perform operations on the keys that enable them to authenticate using the identities loaded into the agent.

Read that carefully: root on the hop cannot steal your key, but for as long as your session is open they can sign with it — i.e. log in as you to every machine that key opens. On a shared or compromised bastion that's a full lateral-movement primitive.

Use ProxyJump instead (§5). It solves the same problem — reach host B via host A — without ever exposing your agent to A, because the SSH session is encrypted end-to-end from your laptop to B and A only relays bytes. Default ForwardAgent no globally, and if some workflow genuinely needs forwarding, scope it to a single trusted host and pair it with ssh-add -c so each use requires an explicit confirmation:

Host *
    ForwardAgent no          # default deny

Host trusted-build-box
    ForwardAgent yes         # opt in for exactly one host, deliberately

4. ~/.ssh/config — host blocks

A config block turns a long command into ssh prod, and it's where bastion routing, identity selection and keepalives live. Every tool that shells out to ssh — git, rsync, scp, VS Code Remote-SSH, Ansible — inherits it for free.

# ~/.ssh/config   (chmod 600)

# ── The bastion: the ONLY box with a public SSH port ──────────────────
Host bastion
    HostName bastion.example.com
    User jumpuser
    Port 22
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes            # offer ONLY this key (see gotcha below)
    ForwardAgent no

# ── A private box reachable only through the bastion ──────────────────
Host app-internal
    HostName 10.0.1.50            # private IP, no public exposure
    User appuser
    ProxyJump bastion             # SSH hops through bastion automatically
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes

# ── Pattern matching: one block for a whole fleet ─────────────────────
Host db-* cache-*
    User ops
    ProxyJump bastion
    IdentityFile ~/.ssh/id_ed25519

# ── A distinct key for GitHub (keeps work/personal identities apart) ──
Host github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_github
    IdentitiesOnly yes

# ── Shared defaults. MUST BE LAST — see first-match-wins below. ────────
Host *
    AddKeysToAgent yes
    ForwardAgent no
    ServerAliveInterval 60        # ping every 60s...
    ServerAliveCountMax 3         # ...give up after 3 misses (~3 min dead link)
    ControlMaster auto            # reuse one TCP+auth session for repeat connects
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

Three things worth internalising:

ssh -G app-internal prints the fully-resolved config for a host — the fastest way to find out which block actually won.


5. Bastions & ProxyJump

The bastion (jump host) pattern: exactly one hardened box has a public SSH port; everything else lives on private IPs and is reached through it. One place to audit, one place to patch, one place to revoke.

Host app-internal
    HostName 10.0.1.50
    ProxyJump bastion
ssh app-internal                                        # config-driven
ssh -J jumpuser@bastion.example.com appuser@10.0.1.50   # one-shot equivalent
ssh -J hop1,hop2 user@final-host                        # chain multiple hops

ProxyJump is not agent forwarding. Under the hood it runs a nested ssh -W host:port on the jump host, which merely relays TCP. Your session to the final host is encrypted end-to-end from your laptop — the bastion sees ciphertext, never your keystrokes and never your agent. That property is the whole reason to prefer it.

flowchart LR
    Dev["Your laptop"] -->|"SSH session, encrypted end-to-end"| App["app-internal 10.0.1.50"]
    Dev -.->|"outer SSH: relay only"| B["bastion :22<br/>sees ciphertext"]
    B -.->|"forwards TCP"| App
    classDef pub fill:#0891B2,color:#fff,stroke:#22D3EE
    classDef priv fill:#1e293b,color:#e2e8f0,stroke:#334155
    class B pub
    class App priv

ProxyCommand is the older, more general escape hatch (ProxyCommand ssh -W %h:%p bastion, or an AWS SSM / Cloudflare cloudflared access ssh invocation). Reach for ProxyJump unless you need something ProxyJump can't express.

On the bastion itself, keep the blast radius small: no application code, no secrets, AllowTcpForwarding yes (it needs it) but AllowAgentForwarding no, per-user accounts, and full session logging.


6. Port forwarding: -L, -R, -D

SSH's most useful and least-known feature: carry an arbitrary TCP stream through the encrypted connection. -N means "no shell, just hold the tunnel open"; add -f to background it.

# LOCAL forward (-L): pull a remote/private service onto YOUR localhost.
# Reach a Postgres on a private subnet as if it were local:5432.
ssh -N -L 5432:db.internal:5432 user@bastion.example.com
#        └local┘ └──remote target──┘
# → psql -h localhost -p 5432   now hits db.internal through the tunnel.
# The "db.internal:5432" part is resolved BY THE BASTION, not by you — which is
# why this works for hostnames that don't resolve on your machine at all.

# REMOTE forward (-R): push YOUR localhost out to a port on the remote box.
ssh -N -R 8080:localhost:3000 user@server.example.com
# → server.example.com:8080 reaches your laptop's :3000.
# By default this binds the remote's LOOPBACK only. To expose it on the remote's
# public interface the SERVER must set `GatewayPorts yes` (or `clientspecified`),
# then: ssh -N -R 0.0.0.0:8080:localhost:3000 user@server.example.com

# DYNAMIC forward (-D): a local SOCKS5 proxy that egresses from the remote host.
ssh -N -D 1080 user@bastion.example.com
# → curl --socks5-hostname localhost:1080 https://internal-dashboard.example.com
# Point a browser's SOCKS settings at localhost:1080 and every request exits
# from the bastion — the cleanest way to browse an internal admin panel without
# forwarding each service individually.
flowchart LR
    Dev["Your laptop<br/>localhost:5432"] -->|"encrypted SSH"| B["Bastion<br/>public :22"]
    B -->|"private LAN"| DB[("db.internal:5432<br/>no public port")]
    classDef pub fill:#0891B2,color:#fff,stroke:#22D3EE
    classDef priv fill:#1e293b,color:#e2e8f0,stroke:#334155
    class B pub
    class DB priv

The mnemonic: -L brings something to you (Local); -R sends something out to the Remote; -D makes everything Dynamic through a SOCKS proxy.

Escape sequences (typed at the start of a line in an interactive session) save you when a forward is missing or a session hangs:

Sequence Effect
~? List all escape sequences
~C Open a command line — add a forward mid-session: -L 8080:localhost:80
~. Kill a hung session (when Ctrl-C won't work)
~& Background the session

On the server side, AllowTcpForwarding no in sshd_config disables all of this — set it on any host that has no business relaying traffic, and leave it on only for bastions.


7. Hardening sshd

The defaults in sshd_config are permissive on purpose — OpenSSH ships something that works everywhere, and expects you to lock it down. These are the directives that matter, with their documented defaults:

Directive Default Set to Why
PasswordAuthentication yes no Passwords are brute-forced continuously; keys are not
KbdInteractiveAuthentication yes no The forgotten back door — PAM keyboard-interactive still accepts passwords even after you disable PasswordAuthentication
PermitRootLogin prohibit-password no Per-user accounts + sudo give you an audit trail
PubkeyAuthentication yes yes Keep explicit so a future edit can't quietly flip it
MaxAuthTries 6 3 Fewer guesses per connection
AllowUsers / AllowGroups all users explicit list Allowlist beats denylist
PermitEmptyPasswords no no Assert it
X11Forwarding no (often yes on distros) no Unused attack surface on a server
AllowTcpForwarding yes no (except bastions) Stops a compromised account pivoting through the box
AllowAgentForwarding yes no Don't let a hop harvest visiting agents
PerSourcePenalties enabled (9.8+) leave on Built-in penalty box for crashes, auth failures, invalid users
# Edit, then ALWAYS validate before restarting
sudo nano /etc/ssh/sshd_config
sudo sshd -t                    # syntax check — silence means OK
sudo sshd -T | grep -Ei 'passwordauth|permitrootlogin|kbdinteractive'   # effective config
sudo systemctl restart ssh

# ⚠ Keep your CURRENT session open and verify a NEW one connects before closing it.
# A typo in sshd_config plus a closed last session is a locked-out server.

Two distro gotchas that waste afternoons:

PerSourcePenalties vs fail2ban

Since OpenSSH 9.8, sshd penalises misbehaving source addresses itself — enabled by default, with per-event penalties (auth failure 5s, invalid user 5s, crash 90s, …) accumulating up to a 10-minute cap. For a single host with keys-only auth, that plus PasswordAuthentication no already removes essentially all brute-force value.

fail2ban still earns its place when you want firewall-level bans (dropping packets rather than answering them), longer ban windows, bans shared across services (SSH + nginx + postfix), or a ban list you can inspect and report on.

# /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 3

[sshd]
enabled = true
# Ubuntu 24.04 / Debian 12 log SSH to journald and may have NO /var/log/auth.log.
# With the default `backend = auto`, fail2ban then reads nothing and silently
# never bans anyone. Point it at the journal explicitly:
backend = systemd
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd     # verify it is actually seeing failures

Beyond authorized_keys: SSH certificates

authorized_keys sprawl is the real operational problem at fleet scale — one departing engineer means editing every server. SSH certificates invert it: a CA signs short-lived user certificates, servers trust the CA, and expiry does the revocation for you.

# On the CA host (protect this key like a root credential)
ssh-keygen -t ed25519 -f ~/ca_user_key -C "codeamani-user-ca"

# Sign a user's public key for 8 hours, valid as principal "appuser"
ssh-keygen -s ~/ca_user_key -I "barnabas@codeamani" -n appuser -V +8h \
  ~/.ssh/id_ed25519.pub          # → produces id_ed25519-cert.pub

# On every server: trust the CA instead of listing individual keys
#   /etc/ssh/sshd_config
#   TrustedUserCAKeys /etc/ssh/ca_user_key.pub

If you'd rather not run a CA, Tailscale SSH (§10) and Cloudflare Access give you the same "identity, not a file on a laptop" property as a managed service.


8. SSH and WSL Ubuntu

WSL trips people up because there are two operating systems with two separate SSH worlds on one machine. See wsl/CLAUDE_CODE_INTEGRATION.md (§7 configuration, §10 networking) for the WSL fundamentals; this section covers only the SSH-shaped parts.

SSH out of WSL (the common case)

The distro's ~/.ssh is at /home/you/.ssh inside the Linux filesystem, entirely separate from Windows' C:\Users\you\.ssh. Simplest correct answer: generate a distro-local key and treat the distro as its own device.

# Inside Ubuntu on WSL
ssh-keygen -t ed25519 -a 100 -C "barnabas@wsl-ubuntu"
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.example.com

Do not symlink ~/.ssh to /mnt/c/Users/you/.ssh. Without [automount] options = "metadata" in /etc/wsl.conf, everything under /mnt/c reports mode 0777, and ssh refuses the key outright with UNPROTECTED PRIVATE KEY FILE. Enabling metadata (then wsl --shutdown, then chmod 600) does make it work, but you've now got one key whose permissions depend on a mount option — a fragile setup. Copying the key in and chmod 600-ing it, or issuing a separate key, is the durable choice.

If you want a single key custody point across both OSes, bridge the Windows agent into WSL with npiperelay + socat (or a wrapper like wsl2-ssh-agent) and set SSH_AUTH_SOCK in your shell rc. That keeps the private key in the Windows agent — or in 1Password/ssh-agent.exe — with WSL holding no key material at all. Worth the setup cost only if you're already curating Windows-side keys.

SSH into WSL

Two distinct problems: getting sshd running inside the distro, and making the distro reachable at all.

# 1. Install and start the server inside Ubuntu
sudo apt update && sudo apt install -y openssh-server
sudo ssh-keygen -A                      # generate host keys if the install didn't

# Avoid colliding with the Windows host's own OpenSSH server on :22
sudo sed -i 's/^#\?Port .*/Port 2222/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sshd -t

# 2. Start it. With systemd enabled ([boot] systemd=true in /etc/wsl.conf):
sudo systemctl enable --now ssh
# Without systemd, WSL has no init — start it per session (or from ~/.bashrc):
sudo service ssh start

Reachability depends on the networking mode:

Mode Reaching WSL's sshd Notes
NAT (default) From Windows itself, localhost:2222 generally works via localhost forwarding. From another machine on the LAN it does not — the distro is a VM behind NAT Bridge it with netsh interface portproxy add v4tov4 listenport=2222 listenaddress=0.0.0.0 connectport=2222 connectaddress=$(wsl hostname -I) — but the WSL IP changes on restart, so this needs re-running
Mirrored (networkingMode=mirrored in %UserProfile%\.wslconfig; Win 11 22H2 + WSL 2.0.9+) WSL mirrors the host's interfaces, so a LAN peer can reach the distro directly Requires opening the Hyper-V firewall for inbound: Set-NetFirewallHyperVVMSetting -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -DefaultInboundAction Allow, or a targeted New-NetFirewallHyperVRule per port

Remember wsl --shutdown after editing .wslconfig — the setting only applies to a fresh VM.

The codeAmani answer is usually neither. Run Tailscale inside the distro (or on the Windows host with mirrored networking) and reach it by MagicDNS name over the tailnet — no port forwarding, no IP that changes on reboot, no router config, and identity-gated access. Reserve the portproxy dance for one-off LAN testing.


9. Public tunnels (ngrok, Cloudflare Tunnel)

Daraja, Stripe, Clerk and every other webhook provider POST to a public HTTPS URL. Your dev server on http://localhost:3000 is invisible to them. A tunnel gives localhost a public HTTPS front door.

ngrok — fastest path

# One-time: register your account's authtoken
ngrok config add-authtoken <YOUR_TOKEN>

# Ephemeral URL — fine for a five-minute test
ngrok http 3000

# Better: bind your account's free STATIC dev domain so the URL survives restarts
ngrok http 3000 --url https://<YOUR-DEV-DOMAIN>.ngrok-free.app

Every ngrok account — free tier included — now gets one automatically assigned static dev domain, so the old ritual of re-registering a fresh random URL in the Daraja portal after every restart is no longer necessary. Bind it with --url and the callback URL you configured stays valid. (--subdomain and --hostname are deprecated in favour of --domain/--url.)

Free plan caveats worth knowing before you debug something that isn't broken:

Cloudflare Tunnel (cloudflared) — durable, no open ports

cloudflared makes an outbound-only connection to Cloudflare's edge; your firewall stays fully closed to inbound traffic, and you get a stable hostname on your own domain (which matters when a provider whitelists callback domains).

cloudflared tunnel login                                   # browser auth, picks a zone
cloudflared tunnel create daraja-dev                       # named tunnel + UUID
cloudflared tunnel route dns daraja-dev cb.codeamani.com   # map a hostname

# ~/.cloudflared/config.yml
#   tunnel: <UUID>
#   credentials-file: /home/you/.cloudflared/<UUID>.json
#   ingress:
#     - hostname: cb.codeamani.com
#       service: http://localhost:3000
#     - service: http_status:404

cloudflared tunnel run daraja-dev                          # bring it up

# Throwaway alternative — no account, no config, random *.trycloudflare.com URL:
cloudflared tunnel --url http://localhost:3000
flowchart LR
    Daraja["Safaricom Daraja"] -->|"HTTPS POST callback"| CF["Cloudflare edge"]
    CF -.->|"outbound-only tunnel<br/>firewall stays shut"| CFD["cloudflared on laptop"]
    CFD --> App["Next.js<br/>localhost:3000<br/>/api/mpesa/callback"]
    classDef accent fill:#0891B2,color:#fff,stroke:#22D3EE
    class CF,CFD accent
ngrok Cloudflare Tunnel Tailscale Funnel
Setup seconds a few minutes seconds (if already on the tailnet)
URL free static dev domain; custom on paid your own domain hostname <device>.<tailnet>.ts.net
Needs a domain you own no yes (a Cloudflare zone) no
Inbound ports none none (outbound-only) none
Best for throwaway webhook testing, request inspection longer-lived previews, IP/domain-restricted providers sharing a dev service you're already meshing

10. VPN & zero-trust (Tailscale, WireGuard, Access)

Tunnels and bastions expose one path at a time. A zero-trust mesh flips the model: machines join a private encrypted network and access is granted by identity + policy, not by where a packet originates.

Tailscale (managed WireGuard mesh)

Tailscale builds an encrypted peer-to-peer WireGuard mesh (a "tailnet"). No central gateway to bottleneck, no inbound ports, and devices authenticate against your existing IdP.

tailscale up                       # auth via browser/IdP, join the tailnet
tailscale status                   # peers + their 100.x.y.z addresses
tailscale ip -4                    # this device's tailnet IPv4

tailscale set --ssh                # enable identity-gated Tailscale SSH on THIS device
ssh pi@raspberry-pi                # connect by MagicDNS name — no authorized_keys at all
tailscale ssh pi@raspberry-pi      # equivalent via the Tailscale CLI

tailscale funnel 3000              # expose a local port publicly at https://<device>.<tailnet>.ts.net

tailscale set --ssh is the current documented way to turn the SSH server on (tailscale up --ssh still works and is what older docs show). Access needs both a normal ACL permitting the connection and an SSH rule in the policy file:

{
  "action": "accept",
  "src":    ["autogroup:member"],
  "dst":    ["autogroup:self"],
  "users":  ["autogroup:nonroot"]
}

"action": "check" instead of "accept" forces periodic re-authentication — sessions reset after 12 hours by default (checkPeriod). Limits to plan around: the SSH server runs on Linux and macOS open-source builds only, port 22 is assumed and not configurable, restarting tailscaled drops live sessions, and "checkPeriod": "always" will break automation like Ansible.

The free Personal plan currently covers up to 6 users with unlimited user devices, 3 ACL groups, 50 tagged resources, and Tailscale SSH on up to 5 hosts — comfortably enough for a dev fleet and a handful of Raspberry Pis. MagicDNS gives you stable names (raspberry-pi) instead of 100.x.y.z addresses; use names in scripts.

WireGuard (the raw protocol)

Tailscale is WireGuard with identity, key distribution and NAT traversal bolted on. Plain WireGuard (wg, wg-quick up wg0) is the DIY option — you manage keys and peer config yourself. Reach for it when you want a single self-hosted VPN concentrator and no third party in the path, and accept that key rotation and device revocation become your job.

Cloudflare Access (zero-trust for HTTP)

Pairs with Cloudflare Tunnel: put an internal app behind a Tunnel, then enforce an identity policy (email domain, IdP group, MFA) at Cloudflare's edge before any request reaches your origin. No VPN client, no open port — the app is private but reachable by exactly the right people. cloudflared access ssh extends the same policy layer to SSH via a ProxyCommand.


11. RDP / VNC

When you need a screen, not a shell:


12. Remote development

Run the editor locally, execute remotely — so code lives next to the data, GPU, or private network it needs.


13. Security checklist

Remote access is the front door to your infrastructure — treat every item as mandatory, not optional.


14. Troubleshooting

Symptom Likely cause / fix
Permission denied (publickey) Public key not in the server's authorized_keys, or wrong user. Diagnose with ssh -vvv user@host and read which keys were offered
UNPROTECTED PRIVATE KEY FILE chmod 600 ~/.ssh/id_ed25519, chmod 700 ~/.ssh. On WSL, the key is probably on /mnt/c — move it into the Linux filesystem (§8)
Key ignored server-side, no error ~/.ssh or authorized_keys too permissive on the server; sshd silently skips them. Check sudo journalctl -u ssh
Too many authentication failures The agent is offering every key before the right one. Add IdentitiesOnly yes + an explicit IdentityFile
REMOTE HOST IDENTIFICATION HAS CHANGED Server rebuilt/re-imaged (or an on-path attack). Confirm the cause, then ssh-keygen -R host
Config edits have no effect First-match-wins: an earlier block (often Host * at the top) already set the keyword. Check with ssh -G host
sshd still accepts passwords after disabling Either KbdInteractiveAuthentication yes is still on, or a drop-in in /etc/ssh/sshd_config.d/ overrides you. Verify with sudo sshd -T | grep -i auth
Port change in sshd_config ignored (Ubuntu 22.10+) ssh.socket owns the port — systemctl edit ssh.socket and set ListenStream=
-R forward not reachable from outside the remote Remote binds loopback by default; needs GatewayPorts yes server-side and -R 0.0.0.0:PORT:...
Connection drops after idle ServerAliveInterval 60 + ServerAliveCountMax 3 in ~/.ssh/config
fail2ban never bans anything (Ubuntu 24.04) No /var/log/auth.log; set backend = systemd in the [sshd] jail
Webhook provider gets a 404 through the tunnel Tunnel points at the wrong port, or cloudflared ingress falls through to http_status:404 — hostname must match exactly
ngrok shows a warning page instead of the app Free-plan browser interstitial. Harmless for webhooks; send ngrok-skip-browser-warning to bypass
Can't reach WSL's sshd from another machine Default NAT mode. Use networkingMode=mirrored + a Hyper-V firewall rule, or netsh interface portproxy (§8)
Tailscale SSH refuses the connection Missing the SSH rule in the policy file — a normal ACL grant alone isn't enough

15. codeAmani notes

Official docs: