Expose local dev server with SSH tunnel and Docker

Introduction Most consumer-grade internet connections are hidden behind CG-NAT and are not reachable from the internet. This is done to save IP addresses, as IPv4 has a limited range. If you have a static public IPv4 or any IPv6 address, you won’t need the setup from this tutorial. There are already services like localtunnel or ngrok for this purpose, but when you actually start using them, you will often find out that they have limitations on their free plans. So, we will configure our own custom setup once and have it always available for convenient and practical usage which will save a lot of time and nerves in the long run. Why is this useful This is useful whenever you need to share your local project with others or provide a publicly accessible URL for your service so that external systems can reach it. This is often the case if you work remotely. Yes, you can use test deployments, but having a tunnel setup configured and being able to run it with a single terminal command saves a lot of time and energy. Possible use cases: Sharing work in progress with clients or teammates Remote debugging or pair programming Demos for presentations or team meetings Testing the frontend on different devices (mobile, different resolution, OS, browser) Testing webhooks from external services (Stripe, GitHub, Oauth, Slack, Contentful, Twilio, etc.) Prerequisites A VPS server with a public IP and Docker A local machine with a working SSH and dev server that you want to expose A domain name (optional) Demo video Architecture overview Without going too deep into computer networking theory, let's explain port forwarding in simplified terms. Port forwarding is a mapping (binding) between two points (services) on a private network (or even on the same machine) that would otherwise be unreachable. You can think of it as a VPN for a single service (port). So it's exactly what we need: we want to bind (redirect traffic from) a public port (1081 in our case) on the VPS, which acts as a gateway, to port 3000 on our local dev server that is not directly reachable from the internet. That’s it for the tunneling part, this setup is sufficient for serving HTTP traffic. Additionally, to support HTTPS and provide a user-friendly URL, we will add Traefik, which will handle HTTPS certificates and route traffic from port 443 to port 1081 of the tunnel. Running the SSH server in Docker We already use SSH to access our VPS, but we prefer to keep that configuration untouched. So, we will run a separate SSH server inside a Docker container specifically for tunneling. For this, we will use linuxserver/openssh-server image. Linuxserver is an organization that maintains very stable Dokcer images for all kinds of purposes. By default, the SSH server doesn’t allow tunneling, so we need to modify the config in /etc/ssh/sshd_config and enable it. # /etc/ssh/sshd_config AllowTcpForwarding yes GatewayPorts yes sudo nano /etc/ssh/sshd_config # edit config... sudo systemctl restart sshd But since we are using a Docker container, we will do it differently. We will use openssh-server-ssh-tunnel mod, which enables tunnelling in the linuxserver/openssh-server image. You can think of mods as presets (additional layers and configurations) for these images. Here is docker-compose.yml for the SSH tunnel container: # docker-compose.yml version: '3.8' services: openssh-server: image: linuxserver/openssh-server container_name: openssh-server restart: unless-stopped hostname: openssh-server #optional expose: - 1081 # tunneled service port, for Traefik ports: - 1080:2222 # 1080 is the main SSH connection port environment: # https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel - DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel - SHELL_NOLOGIN=false # set correct for current host user - PUID=1001 - PGID=1001 - TZ=Etc/UTC # important - PUBLIC_KEY # optional env vars bellow - SUDO_ACCESS=true - USER_NAME=username - PASSWORD_ACCESS=false volumes: - ./config:/config # Traefik configuration bellow labels: - 'traefik.enable=true' - 'traefik.docker.network=proxy' - 'traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)' - 'traefik.http.routers.ssh-tunnel.entrypoints=websecure' - 'traefik.http.routers.ssh-tunnel.service=ssh-tunnel' - 'traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081' # matches exposed port networks: - proxy networks: proxy: external: true Lets explain the code above: services: openssh-server: image: linuxserver/openssh-server # ... ports: - 1080:2222 # 1080 is the main SSH connection port By default, linuxserver/docker-openssh-server runs the SSH service on port 2222, t

Apr 23, 2025 - 11:11
 0
Expose local dev server with SSH tunnel and Docker

Introduction

Most consumer-grade internet connections are hidden behind CG-NAT and are not reachable from the internet. This is done to save IP addresses, as IPv4 has a limited range. If you have a static public IPv4 or any IPv6 address, you won’t need the setup from this tutorial.

There are already services like localtunnel or ngrok for this purpose, but when you actually start using them, you will often find out that they have limitations on their free plans. So, we will configure our own custom setup once and have it always available for convenient and practical usage which will save a lot of time and nerves in the long run.

Why is this useful

This is useful whenever you need to share your local project with others or provide a publicly accessible URL for your service so that external systems can reach it. This is often the case if you work remotely.

Yes, you can use test deployments, but having a tunnel setup configured and being able to run it with a single terminal command saves a lot of time and energy.

Possible use cases:

  • Sharing work in progress with clients or teammates
  • Remote debugging or pair programming
  • Demos for presentations or team meetings
  • Testing the frontend on different devices (mobile, different resolution, OS, browser)
  • Testing webhooks from external services (Stripe, GitHub, Oauth, Slack, Contentful, Twilio, etc.)

Prerequisites

  • A VPS server with a public IP and Docker
  • A local machine with a working SSH and dev server that you want to expose
  • A domain name (optional)

Demo video

Architecture overview

Without going too deep into computer networking theory, let's explain port forwarding in simplified terms. Port forwarding is a mapping (binding) between two points (services) on a private network (or even on the same machine) that would otherwise be unreachable. You can think of it as a VPN for a single service (port).

So it's exactly what we need: we want to bind (redirect traffic from) a public port (1081 in our case) on the VPS, which acts as a gateway, to port 3000 on our local dev server that is not directly reachable from the internet. That’s it for the tunneling part, this setup is sufficient for serving HTTP traffic.

Additionally, to support HTTPS and provide a user-friendly URL, we will add Traefik, which will handle HTTPS certificates and route traffic from port 443 to port 1081 of the tunnel.

SSH tunnel architecture diagram

Running the SSH server in Docker

We already use SSH to access our VPS, but we prefer to keep that configuration untouched. So, we will run a separate SSH server inside a Docker container specifically for tunneling.

For this, we will use linuxserver/openssh-server image. Linuxserver is an organization that maintains very stable Dokcer images for all kinds of purposes.

By default, the SSH server doesn’t allow tunneling, so we need to modify the config in /etc/ssh/sshd_config and enable it.

# /etc/ssh/sshd_config

AllowTcpForwarding yes
GatewayPorts yes
sudo nano /etc/ssh/sshd_config

# edit config...

sudo systemctl restart sshd

But since we are using a Docker container, we will do it differently.

We will use openssh-server-ssh-tunnel mod, which enables tunnelling in the linuxserver/openssh-server image. You can think of mods as presets (additional layers and configurations) for these images.

Here is docker-compose.yml for the SSH tunnel container:

# docker-compose.yml

version: '3.8'

services:
  openssh-server:
    image: linuxserver/openssh-server
    container_name: openssh-server
    restart: unless-stopped
    hostname: openssh-server #optional
    expose:
      - 1081 # tunneled service port, for Traefik
    ports:
      - 1080:2222 # 1080 is the main SSH connection port
    environment:
      # https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel
      - DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel
      - SHELL_NOLOGIN=false
      # set correct for current host user
      - PUID=1001
      - PGID=1001
      - TZ=Etc/UTC
      # important
      - PUBLIC_KEY
      # optional env vars bellow
      - SUDO_ACCESS=true
      - USER_NAME=username
      - PASSWORD_ACCESS=false
    volumes:
      - ./config:/config
    # Traefik configuration bellow
    labels:
      - 'traefik.enable=true'
      - 'traefik.docker.network=proxy'
      - 'traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)'
      - 'traefik.http.routers.ssh-tunnel.entrypoints=websecure'
      - 'traefik.http.routers.ssh-tunnel.service=ssh-tunnel'
      - 'traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081' # matches exposed port
    networks:
      - proxy

networks:
  proxy:
    external: true

Lets explain the code above:

services:
  openssh-server:
    image: linuxserver/openssh-server
    # ...
    ports:
      - 1080:2222 # 1080 is the main SSH connection port

By default, linuxserver/docker-openssh-server runs the SSH service on port 2222, to avoid conflicting with the usual port 22 that is used for host's SSH service and it's hardcoded in the Dockerfile. We will choose port 1080 for the main SSH connection, so we need to map it to port 2222 with SSH in the container. Port 1080 is used for the actual connection over the internet, and it is required to allow that port in VPS firewall.

So, let's establish clear and precise naming from the beginning:

  • Port 1080 - the main SSH connection port
  • Ports 1081, 1082, 1083, ... - tunneled services remote ports

Additionally, you need to configure the SSH client on your dev machine to use port 1080 for SSH when connecting to this container. In this example I have named VPS host amd1 and SSH container host amd1c, you can use your own naming logic.

# ~/.ssh/config

# ssh amd1 ssh container
Host amd1c 123.123.123.123 # VPS IP
    HostName 123.123.123.123
    IdentityFile ~/.ssh/my-keys/amd1_ssh_container__id_ed25519 # private key file name
    User username
    Port 1080

In the client SSH config above, you will notice the private key file amd1_ssh_container__id_ed25519. The public key is passed to the SSH container as an environment variable:

services:
  openssh-server:
    image: linuxserver/openssh-server
    # ...
    environment:
      - PUBLIC_KEY # important

You generate SSH key pairs as usual, e.g.:

ssh-keygen -t ed25519 -C "myemail@gmail.com" -f ~/.ssh/my-keys/amd1_ssh_container__id_ed25519

Now, we choose which remote port we will use to expose our local dev server. If you're using Traefik and don’t access this port directly via the browser, you don’t need to allow it in the VPS's firewall.

services:
  openssh-server:
    image: linuxserver/openssh-server
    # ...
    expose:
      - 1081 # tunneled service remote port

The other environment variables worth mentioning are the following:

services:
  openssh-server:
    image: linuxserver/openssh-server
    # ...
    environment:
      # https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel
      - DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel
      - PUID=1001
      - PGID=1001

We use DOCKER_MODS variable to specify openssh-server-ssh-tunnel mod. PUID and PGID are user and group IDs used to handle permissions between the host and the container. You get their values by running id -u && id -g on the VPS host. It is also a good idea to export them as global environment variables in ~/.bashrc file to make them available for all containers:

# ~/.bashrc

export MY_UID=$(id -u)
export MY_GID=$(id -g)

Then you can pass them like this:

# docker-compose.yml

# ...
environment:
  - PUID=$MY_UID
  - PGID=$MY_GID

The SSH tunnel is now configured. Now you can access your local dev server by HTTP via the your VPS IP e.g. http://123.123.123.123:1081 or domain http://my-domain.com:1081.

Configuring HTTPS with Traefik

Some browsers disallow insecure HTTP traffic by default, and you need to tweak the browser settings to allow it explicitly. This can be inconvenient when sending a demo link to a non-technical person. Additionally, some OAuth providers require HTTPS even for testing (e.g. Facebook). So let's make an extra effort to do things properly and configure a HTTPS with a subdomain using Traefik.

If you are running a VPS, chances are you already use a reverse proxy for handling certificates and subdomain routing. This example shows how to do it with Traefik.

# docker-compose.yml

services:
  openssh-server:
    image: linuxserver/openssh-server
    container_name: openssh-server
    # ...
    expose:
      - 1081 # tunneled service remote port
    ports:
      - 1080:2222 # 1080 is the main SSH connection port

    # Traefik configuration bellow
    labels:
      - 'traefik.enable=true'
      - 'traefik.docker.network=proxy'
      - 'traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)'
      - 'traefik.http.routers.ssh-tunnel.entrypoints=websecure'
      - 'traefik.http.routers.ssh-tunnel.service=ssh-tunnel'
      - 'traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081' # matches exposed port
    networks:
      - proxy

networks:
  proxy:
    external: true

The truth is, there is not much work to do here. All you need to do is to map the remote port of the tunnel 1081 to Traefik and define the URL on which you want to expose your local dev server via the environment variable e.g. SITE_HOSTNAME=preview.my-domain.com.

Everything else is just generic Traefik configuration. Also, don't forget to add the wildcard A record for your subdomains (e.g., you might add a *.tunnels "namespace") in your DNS provider's dashboard and point it to your VPS IP. Additionally, create an external Docker network, e.g. named proxy as shown in the example above.

services:
  openssh-server:
    image: linuxserver/openssh-server
    container_name: openssh-server
    # ...
    expose:
      - 1081 # tunneled service remote port, passed to Traefik

    # ...
    # Traefik configuration bellow
    labels:
      # ...
      - 'traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)' # in .env file: SITE_HOSTNAME=my-domain.com
      - 'traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081' # matches the exposed port

In the end, you just need to define 2 environment variables for your docker-compose.yml inside the .env file:

# .env

# full with subdomain, without 'https://'
SITE_HOSTNAME=my-domain.com # or e.g. preview.my-domain.com

# public ssh key
PUBLIC_KEY=my-public-ssh-key

Above is shown only the relevant Traefik configuration for the SSH tunnel container. A complete Traefik reverse proxy configuration requires additional static and dynamic configurations for the Traefik container, but that is outside the scope of this tutorial. You can search for examples of Traefik configurations or reuse mine, which is available in this repository: nemanjam/traefik-proxy.

Tunneling multiple services

Sometimes your app runs more than a single service, e.g. frontend and backend. If you expose just the frontend from port 3000, note that localhost from, e.g. localhost:5000 won't be resolved. Therefore, you need to tunnel all services and set the tunneled URLs in your .env files.

How to have more than one tunnel? Your first thought might be to run multiple SSH server containers, but fortunately, that is not necessary. You can tunnel as many services as you want through a single SSH connection. You just need to expose multiple ports on the SSH container and map them to multiple Traefik hosts with labels, as shown below:

# docker-compose.yml

version: '3.8'

services:
  openssh-server:
    image: linuxserver/openssh-server
    container_name: openssh-server
    restart: unless-stopped
    hostname: openssh-server #optional
    # tunneled services, remote ports
    expose:
      - 1081 # tunnel1
      - 1082 # tunnel2
      - 1083 # tunnel3
    ports:
      - 1080:2222 # 1080 is the main SSH connection port
    environment:
      # https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel
      - DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel
      - SHELL_NOLOGIN=false
      # set correct for current host user
      - PUID=1001
      - PGID=1001
      - TZ=Etc/UTC
      # important
      - PUBLIC_KEY
      # optional env vars bellow
      - SUDO_ACCESS=true
      - USER_NAME=username
      - PASSWORD_ACCESS=false
    volumes:
      - ./config:/config
    # Traefik configuration bellow
    labels:
      # common config
      - 'traefik.enable=true'
      - 'traefik.docker.network=proxy'

      # tunnel1 (port 3000 -> 1081)
      - 'traefik.http.routers.ssh-tunnel1.rule=Host(`preview1.${SITE_HOSTNAME}`)'
      - 'traefik.http.routers.ssh-tunnel1.entrypoints=websecure'
      - 'traefik.http.routers.ssh-tunnel1.service=ssh-tunnel1'
      - 'traefik.http.services.ssh-tunnel1.loadbalancer.server.port=1081'

      # tunnel2 (port 5000 -> 1082)
      - 'traefik.http.routers.ssh-tunnel2.rule=Host(`preview2.${SITE_HOSTNAME}`)'
      - 'traefik.http.routers.ssh-tunnel2.entrypoints=websecure'
      - 'traefik.http.routers.ssh-tunnel2.service=ssh-tunnel2'
      - 'traefik.http.services.ssh-tunnel2.loadbalancer.server.port=1082'

      # tunnel3 (port 5001 -> 1083)
      - 'traefik.http.routers.ssh-tunnel3.rule=Host(`preview3.${SITE_HOSTNAME}`)'
      - 'traefik.http.routers.ssh-tunnel3.entrypoints=websecure'
      - 'traefik.http.routers.ssh-tunnel3.service=ssh-tunnel3'
      - 'traefik.http.services.ssh-tunnel3.loadbalancer.server.port=1083'

    networks:
      - proxy

networks:
  proxy:
    external: true

If you have a large number of services to tunnel, you might want to use a VPN to access all ports by default, but that's rarely the case.

Another point to make is that the SSH tunnel technique is most suitable for temporarily exposing services for demo purposes. For permanent tunnels, you would need to add autossh to keep the connection alive, but there are better tools for permanent tunnels, such as rapiz1/rathole or fatedier/frp.

Open the firewall on the VPS

For the main SSH connection, you will need to open a port in your VPS firewall, port 1080 in this example. Additionally, if you want to access tunnels directly via a port in the browser without Traefik, you will need to open those ports as well. Be mindful not to open too many unnecessary ports, as every newly opened port increases the attack surface.

Example opened ports in the firewall

Running the tunnel

You start the tunnel with a single command like below. The -R option means remote port forwarding, followed by two IP:port pairs. The first pair is remote, and the second is local. At the end, you have the VPS host.

# command format
ssh -R [remote_addr:]remote_port:local_addr:local_port [user@]gateway_addr

# example:
# amd1c host is defined in ~/.ssh/config
ssh -R *:1081:localhost:3000 amd1c

# access the url, e.g.
https://preview1.my-domain.com

# terminate tunnel, like any ssh connection
exit

You can open multiple tunnels with a single command. Just specify the tunnels one after another before the host. Note that you must have these tunnels defined in your docker-compose.yml for the SSH server (exposed ports and Traefik host labels).

# tunnel frontend at port 3000 and backend at port 5000
ssh \
  -R *:1081:localhost:3000 \
  -R *:1082:localhost:5000 \
  amd1c

# access the urls, e.g.
https://preview1.my-domain.com
https://preview2.my-domain.com/api

Completed code

Conclusion

Port forwarding is a basic networking technique that is very familiar to network engineers, but perhaps not often utilized by developers. It can be very useful and practical, especially in a remote work setting. As described in this tutorial, you just need to run a single container, configure the client and firewall, and once you have it set up, it can save you a lot of time and energy in the long run.

SSH remote port forwarding is just one of the many useful and cool SSH networking tricks. There are many others like dynamic port forwarding, SSH agent forwarding, X11 forwarding, SSH file system, etc. Do you use some of them? Please share in the comments bellow.

References