Tutorial  on  LinuxSecurity

Native SSH Reverse Tunneling with Pomerium

Use Pomerium's native SSH support to publish a local service through a standard reverse SSH tunnel, with OpenID Connect (OIDC) authentication and continuous authorization on every request. Reach services behind Network Address Translation (NAT) without firewall holes or custom agents, and control both who can use the service and who can open the tunnel. All data stays on infrastructure you control.

About This Tutorial

Pomerium is an open source identity- and context-aware access proxy. You may already know the "tunnel a local service to the public internet" pattern from Cloudflare Tunnel or ngrok. This tutorial does the same thing, but with identity-aware access in front: Pomerium's native SSH access lets you publish a service through a standard reverse SSH tunnel (ssh -R). Authentication runs against an identity provider (IdP) you bootstrap locally for this tutorial (Dex, an open source OpenID Connect (OIDC) provider), and Pomerium's context-aware, continuous authorization is enforced on every request to the resulting route.

One difference matters before you start: no traffic leaves your infrastructure. ngrok, Cloudflare Tunnel, and similar services route your traffic through infrastructure the vendor owns. Pomerium is open source and self-hosted, so you (or your org) run the proxy yourself and the tunnel terminates on hardware you control. For teams under compliance, data-residency, or regulatory obligations, that is often the whole reason to reach for this pattern instead of a hosted tunnel: the data path is yours end to end. The flip side is that securing that path is now your responsibility, which is what the Security Considerations at the end cover.

The key is that Pomerium itself is the SSH server you point ssh -R at: it accepts the reverse tunnel on its SSH listener, then proxies incoming requests on the matching route back through that tunnel to your local service. The tunnel's bind hostname is simply an HTTP route Pomerium already knows about. No custom client, no agent, and no public IP or inbound firewall rule on the machine running the service.

What makes this more than a fancier ngrok is that you get two independent controls on the same route:

  • Who can use the service (standard Pomerium route policy): governs which identities can reach the published HTTPS URL.
  • Who can open the tunnel (upstream_tunnel.ssh_policy): governs which identities can open the reverse tunnel that backs the route in the first place.

Reach for this when Pomerium can't open a connection to your service, but your service can open one out to Pomerium: a dev box behind Network Address Translation (NAT), a continuous integration (CI) runner, an on-call laptop, anything without a stable public address.

This is a standalone tutorial, but it builds on the same native SSH machinery as Native SSH Access with Pomerium. If you want the "SSH into a host through Pomerium" story first, start there; otherwise this stands on its own.

By the end, you'll have:

  • A local HTTP service published through Pomerium with no inbound exposure
  • A standard ssh -R reverse tunnel terminating at Pomerium
  • OIDC authentication required on the published route
  • Two separate policies: one for who can reach the service, one for who can open the tunnel
  • A live demonstration of each working on its own: opening the route to the public to pull the page through the tunnel, then cutting that page off by revoking the tunnel policy, all without dropping the SSH connection

Prerequisites

  • Comfortable with ssh on the command line
  • Familiar with SSH port forwarding, or at least the idea of it. We explain -R versus -L below
  • Basic familiarity with OAuth or OIDC. You don't need to be an expert; this tutorial bootstraps the IdP (Dex) for you, and you sign in with a local user whose email you choose

The playground on the right provides the VM, Docker, and network. No local installs are required.

How It Works

In a normal reverse SSH tunnel, you run ssh -R <remote-port>:<local-host>:<local-port> and the SSH server starts listening on <remote-port>, forwarding anything that arrives there back down the tunnel to your local service. Pomerium plays the role of that SSH server, with one twist: instead of opening a raw listener, it binds the tunnel to an HTTP route you've defined.

So the flow looks like this:

  1. A service runs locally, say a Go HTTP server on localhost:3000. Pomerium has no way to connect to it.
  2. You open a reverse tunnel to Pomerium's SSH listener, asking it to back the route with your localhost:3000.
  3. Pomerium checks upstream_tunnel.ssh_policy for that route. If your identity is allowed to open the tunnel, it wires it up to the route.
  4. A visitor browses to the published URL. Pomerium authenticates them via OIDC, checks the route policy, and if they're allowed, proxies the request down your tunnel to localhost:3000.

The service is never directly reachable. Every request rides the outbound tunnel, and every request is authenticated and authorized by Pomerium first.

A local Go HTTP server on localhost:3000 opens a reverse SSH tunnel to Pomerium's SSH endpoint on localhost:2222, gated by the upstream_tunnel.ssh_policy tunnel policy; a browser reaches the same service over HTTPS at the exposed playground URL, gated by the access policy, with the self-hosted Dex OIDC provider handling authentication, after which Pomerium issues the ephemeral certificate it uses to authenticate the tunnel.

A note on -R versus -L

This tutorial uses -R (a remote, or reverse, forward) because that's the case Pomerium can't otherwise solve: reaching a service that lives somewhere Pomerium can't connect inbound. The service dials out to Pomerium, so NAT, dynamic IPs, and locked-down firewalls stop mattering.

The other direction, -L (a local forward), reaches a service on a private subnet through a bastion, for example a database or admin UI you can only get to from inside a virtual private cloud (VPC). Pomerium supports that too (enable the ssh_allow_direct_tcpip runtime flag for ssh -L and ssh -J/ProxyJump), but in a Pomerium deployment it's often redundant: Pomerium is already a reverse proxy, so you'd usually just define a route to that internal service directly. The reverse tunnel is the one that gives you something you genuinely can't get another way.

Step 1: Set Up the Project

The playground has scaffolded the working directory, provisioned a TLS certificate for Pomerium, and pre-generated the SSH keys Pomerium's SSH proxy needs. Move into it:

cd ~/pomerium-tunnel

The keys/ directory already contains a User Certificate Authority (CA) key pair and three SSH host key pairs. Pomerium uses the CA key to sign a short-lived certificate for you after you authenticate via OIDC, and presents the host keys to your ssh client so it can verify it's talking to the right server. The Native SSH Access with Pomerium tutorial walks through generating and understanding these keys by hand; here they're ready to go.

Step 2: Run the Local Service

This is the service we'll publish, standing in for whatever you'd run on a laptop or CI box that Pomerium can't reach. It's a tiny Go HTTP server that returns a one-line greeting.

cat > main.go << 'EOF'
package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello world!")
    })
    log.Println("listening on :3000")
    log.Fatal(http.ListenAndServe(":3000", nil))
}
EOF

Run it in a throwaway Go container, detached with -d and published only on loopback so nothing but the tunnel can reach it. Detached keeps it off your terminal, so you don't need a second one. The first run compiles main.go before serving, so the host port will be published before the Go process is listening:

docker run -d --rm --name local-service -v "$PWD":/app -w /app -p 127.0.0.1:3000:3000 golang:1.26.4 go run main.go

To stop the service later, run docker rm -f local-service.

Keeping the service on 127.0.0.1:3000 and off Pomerium's Docker network is deliberate. Pomerium runs in a container on its own Docker network; the Go server publishes only to the VM host's loopback. A container can't reach the host's 127.0.0.1, and the service never joins Pomerium's network, so Pomerium has no route to :3000 at all. Both happen to sit on this one playground VM, but Pomerium can reach the service only through the reverse tunnel, exactly as if it were running on a laptop behind NAT. That asymmetry (the service can dial out to Pomerium, Pomerium can't dial in) is exactly what a reverse tunnel is for.

Confirm it's up:

curl http://localhost:3000/

You're after Hello world!. While the container is still compiling main.go, the published port is already bound but nothing is listening behind it, so the first few attempts return curl: (56) Recv failure: Connection reset by peer. Give it a few seconds and retry until the greeting comes back.

Step 3: Configure Pomerium

Pomerium needs an identity provider (IdP) for authentication. This tutorial uses Dex running in a container alongside Pomerium. The Dex config is pre-generated; the only input it needs from you is your email address, which gets patched in during the next step.

The Pomerium config has shared_secret and cookie_secret generated inline. The unquoted heredoc means $(head -c32 /dev/urandom | base64) runs once when the file is written, so each deployment gets its own fresh secrets.

cat > pomerium-config/config.yaml << EOF
# Secrets: generated fresh for this deployment
shared_secret: $(head -c32 /dev/urandom | base64)
cookie_secret: $(head -c32 /dev/urandom | base64)

address: :443
authenticate_service_url: REPLACE_WITH_ADDRESS

# Authenticate against the self-hosted Dex OIDC provider.
# idp_provider_url is Dex's public issuer URL; the client id/secret
# match the static client in the pre-generated dex-config/config.yaml.
idp_provider: oidc
idp_provider_url: REPLACE_WITH_DEX
idp_client_id: pomerium
idp_client_secret: pomerium-client-secret
idp_scopes:
  - openid
  - email
  - profile
  - offline_access

# SSH proxy settings
ssh_address: 0.0.0.0:2222

ssh_user_ca_key_file: /etc/pomerium/keys/pomerium_user_ca_key
ssh_host_key_files:
  - /etc/pomerium/keys/pomerium_ssh_host_ed25519_key
  - /etc/pomerium/keys/pomerium_ssh_host_rsa_key
  - /etc/pomerium/keys/pomerium_ssh_host_ecdsa_key

# Enable reverse (upstream) SSH tunnels
runtime_flags:
  ssh_upstream_tunnel: true

# Persist databroker state across restarts (single-node only)
databroker_storage_type: file
databroker_storage_connection_string: file:///var/pomerium/databroker

routes:
  - from: REPLACE_WITH_ROUTE
    to: http://non-existent-route
    policy:
      # Who can VISIT the published service
      - allow:
          and:
            - email:
                is: REPLACE_WITH_EMAIL
    upstream_tunnel:
      ssh_policy:
        # Who can OPEN the reverse tunnel that backs this route
        - allow:
            and:
              - email:
                  is: REPLACE_WITH_EMAIL
EOF

A few things to notice:

  • idp_provider: oidc points Pomerium at a generic OIDC provider, and idp_provider_url is Dex's public issuer URL (the third exposed URL you'll paste in Step 4). The idp_client_id and idp_client_secret match the pre-generated Dex config; they already agree.
  • REPLACE_WITH_ADDRESS, REPLACE_WITH_ROUTE, REPLACE_WITH_DEX, and REPLACE_WITH_EMAIL are placeholders. The next step patches them with your playground's exposed URLs and your email address.
  • The ssh_upstream_tunnel: true runtime flag is what enables Pomerium to accept reverse tunnels at all. Without it, the ssh -R connection will authenticate but the tunnel won't open.
  • The to: value is a deliberate placeholder. The real upstream isn't known until the tunnel connects, so this value is overridden by whatever the ssh -R tunnel supplies at connect time. It only needs to be present and syntactically valid.

Step 4: Get Your Playground URLs and Email

Pomerium and Dex need three public URLs and your email address before they can start. Click the port expose icon in the top-right corner of the playground to open the exposed-ports dialog.

  1. Enter 443 for the port number, enable the public toggle, set the HTTPS toggle to Yes, and click Expose. A docker-01:443 row appears with an https://… URL. Copy it. This becomes Pomerium's authenticate service URL (where Open Authorization (OAuth) callbacks land).
  2. Enter 443 again, enable the public toggle, set the HTTPS toggle to Yes, and click Expose again. The playground adds a second docker-01:443 row with a different subdomain. Copy this second https://… URL. This becomes the public route URL (where visitors browse to reach your tunneled service).
  3. Enter 5556 this time, enable the public toggle, leave the HTTPS toggle off (set to No), and click Expose. A docker-01:5556 row appears with its own https://… URL. Copy it. This becomes Dex's public issuer URL (where your browser is redirected to log in).

The first two URLs share container port 443: both subdomains map to it, and Pomerium tells them apart by hostname (one is the authenticate service, the other is the tunneled route). Dex is a separate container listening on port 5556, so it needs its own exposed port and its own subdomain.

The HTTPS toggle controls how the playground ingress talks to the container, not whether the public URL uses HTTPS (it always does). Set it to Yes for both port-443 entries because Pomerium terminates TLS on its listener. Leave it off for port 5556 because Dex serves plain HTTP. Getting this backwards produces an upstream connect error with a TLS WRONG_VERSION_NUMBER in the details for whichever port is misconfigured.

The Expose HTTP(S) Ports dialog with the port entered, the Public toggle on and the HTTPS toggle set to Yes, ready to click Expose.

You end up with three rows (two docker-01:443 and one docker-01:5556), each with its own https://… URL. The two Pomerium rows have HTTPS set to Yes; the Dex row has HTTPS set to No:

The Expose HTTP(S) Ports dialog listing the exposed entries: two docker-01:443 rows with HTTPS set to Yes and one docker-01:5556 row with HTTPS set to No.

Paste the first URL (the authenticate URL) below:

Paste the second URL (the route URL) below:

Paste the third URL (the docker-01:5556 Dex URL) below:

Now enter the email address you'll use to log in. This becomes your Dex user's email and the identity both policies are checked against, so pick the address you'll type at the Dex login screen:

cat ~/pomerium-tunnel/dex-password

A few things to notice:

  • The first exposed URL is used for authenticate_service_url; the second for the route from; the third for idp_provider_url (Dex's issuer). The first and second must differ, which is why you expose port 443 twice; the third is a separate container, so it gets its own port.
  • The email you entered is patched into three places: the route policy, the upstream_tunnel.ssh_policy, and your Dex user. So the account you log in with at Dex is exactly the one both policies check. We'll pull the two policies apart in Step 7 to show the controls working independently.

Step 5: Create the Docker Compose Stack

The stack has two services: Pomerium and Dex.

cat > docker-compose.yml << 'EOF'
services:
  pomerium:
    image: pomerium/pomerium:v0.32.8
    restart: unless-stopped
    environment:
      # TLS certificate provisioned at playground startup
      CERTIFICATE_FILE: /etc/pomerium/certs/cert.pem
      CERTIFICATE_KEY_FILE: /etc/pomerium/certs/key.pem
    volumes:
      - ./pomerium-config:/pomerium:ro
      - ./keys:/etc/pomerium/keys:ro
      - ./certs:/etc/pomerium/certs:ro
      - pomerium-data:/var/pomerium
    ports:
      - "443:443"
      - "127.0.0.1:2222:2222"
    networks:
      - pomerium-net

  dex:
    image: ghcr.io/dexidp/dex:v2.41.1
    restart: unless-stopped
    command: ["dex", "serve", "/etc/dex/config.yaml"]
    volumes:
      - ./dex-config/config.yaml:/etc/dex/config.yaml:ro
    ports:
      - "5556:5556"
    networks:
      - pomerium-net

volumes:
  pomerium-data:

networks:
  pomerium-net:
EOF

Pomerium's published port maps to :443 inside the container, where it terminates TLS directly using the certificate provisioned in setup; both 443 subdomains share that host port, and Pomerium distinguishes them by hostname. Dex exposes its own 5556 and the third exposed subdomain reaches it over plain HTTP, which is why its HTTPS toggle stays off.

Both services share pomerium-net, but Pomerium does not reach Dex over the Docker network. OIDC discovery is keyed on the issuer URL, so Pomerium talks to Dex through the public issuer URL (out to the ingress and back), exactly as your browser does. That's the price of one issuer URL that resolves identically from inside and outside the Docker network.

The Pomerium volume mounts the pomerium-config/ directory rather than the config.yaml file directly. Editors save atomically (write a temp file, then rename it over the original), which swaps the file's inode. Bind-mounting a single file pins the original inode and silently misses your edits; directory mounts re-resolve by name on each access, so the container sees your changes. This matters in Step 7, where we edit policy live. (Dex's config is mounted as a single read-only file because it isn't edited live.)

Start the stack. If a previous run left containers behind, up -d will fail with a name conflict. Tear down first:

docker compose down -v 2>/dev/null; docker compose up -d
docker compose logs -f

Wait until you see Pomerium log "starting SSH listener" and Dex log listening (http) on 0.0.0.0:5556, then press Ctrl+C to exit the log follow and return to the shell prompt before proceeding.

Step 6: Open the Reverse Tunnel

Before running the tunnel command, grab your Dex password so it's ready to paste at the sign-in prompt:

cat ~/pomerium-tunnel/dex-password

Now open the reverse tunnel that publishes the local service. This is a plain ssh -R, the same command you'd use against any SSH server:

URL=$(cat /tmp/pomerium-tunnel.route-address 2>/dev/null | tr -d '[:space:]' | sed 's|https://||')
ssh -R "${URL}:443:localhost:3000" -N localhost -p 2222

Reading the command:

  • -R <URL>:443:localhost:3000 asks the server to forward traffic for the route URL on port 443 back to localhost:3000 on this machine. Pomerium reads the bind hostname as the route to attach the tunnel to, rather than opening a raw socket.
  • -N means "no remote command." You're not opening a shell, you're only setting up forwarding, so the connection just sits there holding the tunnel open.
  • localhost -p 2222 is Pomerium's SSH listener. We use 2222 (not the SSH default 22) because the playground VM already runs its own sshd on port 22, so Pomerium's listener binds to 2222 to avoid the conflict. On a host where Pomerium owns port 22, you can drop the -p.

Notice there's no user@route@host in front, unlike logging into a host through Pomerium. You're not asking for a shell on a target, you're opening a reverse tunnel to Pomerium, and the route is named by the -R bind host.

On the first connection you'll see the standard SSH host-key prompt (Pomerium's host key being added to your known_hosts). Answer yes, and Pomerium prints a sign-in URL:

laborant@docker-01:pomerium-tunnel$ ssh -R 6a1dfc1c220709168d22b045-e20d33.node-eu-11b2.iximiuz.com:443:localhost:3000 -N localhost -p 2222
The authenticity of host '[localhost]:2222 ([127.0.0.1]:2222)' can't be established.
ED25519 key fingerprint is SHA256:dxPrU19l2x2uAUrCF+3MnWLvizTQPHQJSCQfM1CDl4o.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '[localhost]:2222' (ED25519) to the list of known hosts.
Please sign in with oidc to continue
https://6a1dfc1c220709168d22b045-e20d33.node-eu-11b2.iximiuz.com/.pomerium/sign_in?user_code=DvCD0DfivWVzO_K7XqMl5g

Open that URL in a browser. Pomerium redirects you to your Dex login page; sign in with the email you entered in Step 4 and the password you copied above. Pomerium then shows a Verify Sign In page with the pending session details; click Authorize. This is the identity that upstream_tunnel.ssh_policy is checked against.

Once authorized, the terminal goes quiet and stays connected. That's the tunnel holding open. Leave this terminal alone for the rest of the tutorial.

If ssh fails immediately with Permission denied (publickey) and you never see a sign-in URL, the client has no SSH identity key to offer (common on a fresh laptop, Windows Subsystem for Linux (WSL), or CI runner with an empty ~/.ssh/). Pomerium needs the client to attempt publickey auth so it can add the keyboard-interactive method that initiates the browser sign-in flow. Create a key with ssh-keygen -t ed25519 -N '' and retry. The playground VM already ships one for laborant, so this won't bite you here.

Step 7: Reach the Service, Then Tune the Two Policies

The route is live, the tunnel is up, and access is gated by your email address in the policy. You'll confirm that in a browser, then pull the access policy and the tunnel policy apart to see each one work on its own.

Because the published URL is a real HTTPS endpoint (terminated by the playground ingress with a valid certificate), you test it directly in your own browser.

Access the gated route

Open the route URL (the second docker-01:443 URL you pasted in Step 4) in a new browser tab. The quickest way is to click the link straight from the exposed-ports dialog:

You should see the Dex sign-in page if you're not already authenticated, or Hello world! if your session from Step 6 is still active.

The Dex sign-in page showing the email address and password fields.

If you see the sign-in page, log in with the same email and password you used for the SSH tunnel. After authentication, Pomerium checks the route policy and, since your email matches, proxies the request down the tunnel to your local service rendering "Hello world!" in the browser.

If you see a Pomerium user details page instead, you have the authenticate URL open, not the route URL. Close that tab and use the second docker-01:443 link from Step 4.

Pomerium user details page shown when the authenticate URL is opened instead of the route URL.

The request path was: your browser → the playground ingress → Pomerium's HTTPS listener (:443) → down the reverse tunnel → localhost:3000 on the side that opened the tunnel → back. Pomerium checked that your authenticated identity matches the route policy and proxied the request through. Both the tunnel and the route policy are working.

Revoke the route access policy

The tunnel terminal from Step 6 is still holding your connection open, so open a second terminal on docker-01 using the playground's new-terminal control (the split or + button next to the current tab), then move into the project directory:

cd ~/pomerium-tunnel

Introduce a typo into the email on the route policy's is: line. This sed invocation appends .typo to that email and only that one, leaving the tunnel policy untouched, so you can revert it without retyping anything:

sed -i -E 's/^( {16}is: .*)/\1.typo/' pomerium-config/config.yaml

The tunnel is still open and your browser session is still valid, but the route policy no longer matches. Refresh the browser tab:

Pomerium returning a 403 Forbidden page for the published route after the route policy email was changed away from yours.

Pomerium returns a 403 Forbidden. The tunnel works, your session is good, but the route says "no." That's the first control.

Restore the route policy

Strip the typo back off the route policy's is: line:

sed -i -E 's/^( {16}is: .*)\.typo$/\1/' pomerium-config/config.yaml

Refresh the browser tab. Hello world! is back. The route policy governs who can reach the service, and flipping it on and off works in real time.

Revoke the tunnel policy and watch the page go dark

Now cut the other control. Back in your second terminal, append .typo to the email under upstream_tunnel: ssh_policy:. This sed invocation anchors on the tunnel policy's deeper indentation, so it touches only that line and leaves the route policy's email unchanged:

sed -i -E 's/^( {18}is: .*)/\1.typo/' pomerium-config/config.yaml

Policy changes take effect immediately. First, glance at the tunnel terminal from Step 6: it's still connected and quiet. Revoking the tunnel policy does not drop your ssh -R session, because that session is gated only by your identity provider login, which is still valid. What the tunnel policy controls is whether your tunnel is allowed to back this route, so revoking it detaches the tunnel from the route and leaves the route with no upstream to serve.

Refresh the browser tab:

Pomerium returning an Error 503 "Web Server is down" page: the browser and Pomerium both show as working, but there is no upstream behind the route.

Hello world! never comes back. Your session is still good and the route policy still matches, but with no authorized tunnel behind the route, Pomerium has no upstream to proxy to, so it returns a 503 (no healthy upstream).

Those are the two controls pulled fully apart. The route policy still says "this user may visit," yet the page is dark, because upstream_tunnel.ssh_policy cut off who may publish it. Visitor access governs demand; the tunnel policy governs supply. Revoke supply and an open door just leads to an empty room.

Restore the tunnel policy and watch it come back

Strip the typo back off the upstream_tunnel: ssh_policy: line:

sed -i -E 's/^( {18}is: .*)\.typo$/\1/' pomerium-config/config.yaml

Pomerium re-evaluates the tunnel policy on save and re-attaches your still-open tunnel to the route. You didn't touch the Step 6 terminal and you didn't reopen anything: the same connection that sat there the whole time is serving the route again. Refresh the browser tab:

The published route serving "Hello world!" again after the tunnel policy was restored, without reopening the SSH connection.

Hello world! is back. (If it doesn't return within a few seconds, check the Step 6 terminal; if that connection dropped for any reason, reopen it with the same ssh -R command and refresh again.)

You've now exercised both controls independently: the route policy governs who can reach the service (allowed, then revoked, then restored), and upstream_tunnel.ssh_policy governs who can open the tunnel that backs it (cut off on a live, still-connected session, then restored, all without ever touching the tunnel terminal).

Security Considerations

A few things worth knowing before adapting this to a production deployment:

  • No traffic leaves your infrastructure. Unlike hosted tunnel services, both the tunnel endpoint and the proxy run on hardware you control, so there's no third party in the data path to vet, trust, or account for in a compliance review. The flip side is that securing that path is now your responsibility: the points below are the ones that matter most.
  • upstream_tunnel.ssh_policy is your control over who can expose what. Anyone allowed to open a tunnel can forward arbitrary local ports out through Pomerium. Scope this policy as tightly as the access policy, and prefer per-route tunnel policies over a blanket allow.
  • Harden the identity provider for production. This tutorial runs Dex with an in-memory store, a single hardcoded password, and skipApprovalScreen, which is fine for a disposable lab but not for production. Pomerium works with any OIDC-compliant identity provider; see the Pomerium identity provider docs for setup guides covering Okta, Entra ID, Google, and others. Whichever provider you use, rotate the idp_client_secret, back it with persistent storage, and serve it over real TLS.
  • Replace the self-signed certificate for production. The playground provisions a self-signed cert at startup; the CERTIFICATE_FILE and CERTIFICATE_KEY_FILE environment variables in the compose file point to it. In production, provision a cert from a trusted Certificate Authority (CA) for the domain Pomerium serves, and set those same variables (or the certificate_file / certificate_key_file config-file keys). Pomerium also supports automatic Let's Encrypt management via Autocert. See the Certificates reference for all options.
  • The User CA private key is your SSH trust root. Anyone with keys/pomerium_user_ca_key can mint certificates Pomerium's SSH proxy trusts. Protect it like any CA signing key (restricted permissions, a dedicated host, ideally a hardware security module), not in a working directory next to the compose file.
  • The loopback-only binding of :2222 is deliberate. The compose file binds Pomerium's SSH listener to 127.0.0.1:2222 because the playground only exposes :443. If you replicate this outside the playground and the tunnel is opened from elsewhere, bind to 0.0.0.0 and make sure inbound SSH reaches Pomerium rather than a host-level sshd on the same box.

Cleanup

The playground VM is disposable, so containers, volumes, and generated keys disappear when you close this tab.

If you're following this outside the playground, stop the tunnel with Ctrl+C in its terminal, stop the Go container with docker rm -f local-service, and tear the stack down with docker compose down -v. The -v flag also removes the databroker volume, clearing Pomerium's persisted session state.

Next Steps

About the Author

Nick Taylor

Nick Taylor

Nick is a GitHub Star, AAIF Ambassador, Microsoft MVP, AWS Community Builder, Software Developer, and Developer Advocate. With over two decades in technology and a decade of open source contributions, plus six years of professional open source work at companies like OpenSauced, dev.to, Netlify and now Pomerium, he brings deep community knowledge to his work. You'll often find him live streaming tech content, either solo or with friends from the community.

Find this author online

Writes about

linuxsecurity

Frequently covers

deploymentdockerdockerfilesecurity-policy