﻿---
title: Use Docker and SSH to Work Around Old Ubuntu Dev Environment Compatibility Issues
date: 2025-04-25
excerpt: Ubuntu 18.04's glibc is too old for newer VS Code Remote SSH Server and some Node.js binaries. Run the dev environment in Docker and SSH into it.
tags:
  - Docker
  - SSH
  - VSCode
  - Linux
updated: 2026-06-04 19:50:17
lang: en
i18n:
  cn: /docker-ssh-forward
  translation: 2
---

## The Problem

I recently got access to a remote development machine that was still on Ubuntu 18.04 LTS, with glibc 2.27. The system itself worked, but newer development tools were already running into that system-library boundary:

- Newer versions of VS Code connect through Remote SSH by starting VS Code Server on the remote machine. That server has its own glibc requirement, and Ubuntu 18.04's glibc 2.27 is too old, so the remote install or startup can fail.
- Newer Linux x64 prebuilt binaries of Node.js also depend on a newer glibc. On Ubuntu 18.04, installing through nvm or a downloaded binary may hit errors such as `GLIBC_2.28 not found`; the alternatives are using an older Node.js version or compiling it yourself.
- fastfetch is not painless on Ubuntu 18.04 either. You need to compile the executable yourself.

So the problem is not just that one package refuses to install. The deeper issue is that the old system libraries no longer match the default assumptions of newer toolchains.

If you fully control the machine, upgrading the system is the cleanest answer. But internship, lab, or company-provided development machines often come with extra constraints: you may not be allowed to change the OS version, and you may not want to mutate the original environment into something hard to roll back. That leads to the second route: keep the host unchanged, put the development environment inside a Docker container, and SSH directly into that container.

## Two Options

There are two choices here:

1. Upgrade the remote host directly.
2. Run a Docker container on the remote host, then SSH directly into that container.

The first option changes the whole machine. It is easiest to maintain afterward, but only if you have permission and can accept the impact of the upgrade. The second option only uses Docker to start a newer environment on top of the old system. It is less invasive and easier to copy to the next machine.

> [!INFO] Prompt notation
> `>` - LocalHost
> `$` - Remote Host (Ubuntu 18)
> `#` - Docker Container

Below, I first include the system-upgrade path, then focus on the Docker SSH forwarding setup.

### Option 1: Upgrade the Remote Host

If you control the whole machine, upgrading Ubuntu is the most direct fix. The following commands upgrade the system to the next LTS release, with fairly detailed interactive prompts along the way.

```shell
# Update packages
$ sudo apt-get update
$ sudo apt-get upgrade
$ sudo apt-get dist-upgrade
# Install the upgrade tool
$ sudo apt install update-manager-core
# Edit the upgrade config file, set the last line to lts, then save and exit
$ sudo vim /etc/update-manager/release-upgrades
Prompt=lts
# Run the upgrade. It will ask whether to reboot, or you can reboot manually
$ sudo do-release-upgrade
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.6 LTS
Release:        20.04
Codename:       focal
```

### Option 2: SSH into a Docker Container

The Docker approach is simple: the old system only runs Docker and forwards ports; the real development environment lives inside the container. Inside the container, you can use a newer Ubuntu, Debian, or a project-specific base image. From your local machine, you still connect through SSH.

```mermaid
graph LR
    A[Windows host] -->|Direct SSH connection| B[Ubuntu server]

    AA[Windows host] -->|SSH connection| BB[Ubuntu server]
    BB -->|Start container and map container port 22 to Ubuntu port 8033| CC[Docker container]
    AA -->|SSH ubuntu:8033 port| CC
```

The benefit is similar to using Docker as a development environment in general: the environment can be standardized, the image can be copied, and the host stays cleaner. The difference is that this setup also runs an SSH service inside the container, so tools like VS Code Remote SSH can connect to it as if it were a normal server.

## Build an SSH-Ready Image

First, create a directory for building the Docker image, then copy directories such as `.ssh`, `.config`, and `.vscode-server` into it. Most of these settings can be reused, especially SSH keys and common shell configuration.

![SSH Forwarding](https://assets.vluv.space/ssh_forward_1.webp)

The image needs to do a few things:

- Install necessary tools: vim, SSH server, fish shell, and fzf.
- Configure SSH to allow root login and set a temporary password.
- Copy the host's SSH key into the container, so logging in does not require a password.
- Expose port 22, which the host can later map to another port.

> [!NOTE] Security boundary
> The `root:root` password and `PermitRootLogin yes` setting below are suitable for a temporary internal development environment. For long-term use, it is better to create a separate user, disable password login, and keep only key-based login.

```dockerfile
FROM artifactory.momenta.works/docker/python:3.11

# Install vim and other required packages
RUN apt-get update && \
    apt-get install -y vim openssh-server fish fzf

# Copy SSH and shell config
COPY ./.ssh /root/.ssh/
COPY ./.config /root/.config/

# Skip the first-connection confirmation
RUN echo "Host *\n\tHostKeyAlgorithms +ssh-rsa\n\tPubkeyAcceptedKeyTypes +ssh-rsa\n\tStrictHostKeyChecking no" > /root/.ssh/config

RUN sed -i 's/^#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config

RUN echo 'root:root' | chpasswd
RUN chmod 600 /root/.ssh/id_rsa
RUN mkdir /var/run/sshd

EXPOSE 22

# Start the SSH daemon service
ENTRYPOINT service ssh start && tail -f /dev/null
```

Writing the entire image configuration into a Dockerfile in one pass can be annoying. One practical workflow is to start with a simple Dockerfile, create a container, enter the container shell, and install the required environment there. After the basic development environment is ready, use `docker commit` to package the container into a new image.

```shell
# Create the container
$ docker run -it --name dev-image --hostname=dk --privileged=true --net=bridge -p 8033:22 debian:bookworm
# Enter the container
$ docker exec -it dev-image /bin/bash
# Install the required environment
$ apt-get update && \
    apt update && \
    apt install <xxx>
# Exit the container
$ exit
# Commit the container as a new image
$ docker commit dev-image dev-image:latest
```

This is more like drafting the environment by hand inside the container: first find a setup that works, then freeze the stable version as an image. Once the dependencies are clear, it is much easier to go back and clean up the Dockerfile.

## Build and Run the Container

After the Dockerfile is ready, build the image:

```shell
$ docker build -t dev-image:latest .
```

Then run the container and map the container's port 22 to port 8033 on the Ubuntu host:

```shell
$ docker run -it --name ssh --hostname=dk --privileged=true --net=bridge -p 8033:22 dev-image:latest
```

The parameters mean:

- `--name ssh`: set the container name to `ssh`.
- `--hostname=dk`: set the container hostname.
- `--privileged=true`: give the container privileged mode.
- `--net=bridge`: use bridge networking.
- `-p 8033:22`: map container port 22 to host port 8033.

> [!NOTE] Mounting volumes
> If you need persistent data, mount a volume with `-v`, for example: `-v /path/on/host:/path/in/container`.

After the container starts, you can also enter it from the host first and check the SSH service:

```shell
$ docker exec -it <container-name> /bin/bash
$ docker exec -it <container-name> /bin/fish
```

## Configure Local SSH

Once the container port is mapped, you only need to add one more SSH Host locally. In the example below, `ubuntu` points to the host machine, while `docker` points to port 8033 on the same host, which is the container's port 22.

Edit `~/.ssh/config`:

```shell
# Read more about SSH config files: https://linux.die.net/man/5/ssh_config
Host ubuntu
    HostName 10.21.163.77
    User root
    IdentityFile C:/Users/gjx/.ssh/id_rsa
    StrictHostKeyChecking no

Host docker
    HostName 10.21.163.77
    User root
    Port 8033
```

After that, run `ssh docker` locally, or choose `docker` in VS Code Remote SSH. You will be connected to the Docker container.

## How It Works

This connection has only two forwarding layers:

1. Windows first connects to port 8033 on the Ubuntu host.
2. The Ubuntu host forwards port 8033 to port 22 inside the Docker container.

```mermaid
sequenceDiagram
    participant W as Windows
    participant U as Ubuntu host
    participant D as Docker container

    W->>U: SSH connection to port 8033
    U->>D: Port mapping 8033->22
    D-->>W: SSH session established

    Note over W,D: The effect is a direct connection
```

From the local machine's perspective, `docker` is just a normal SSH Host. From the host's perspective, it only forwards port traffic to a container. Once that model is clear, extending it to multiple containers is natural.

When you need to connect to multiple Docker containers, map each container to a different port, then add corresponding entries to your SSH config:

```shell
Host docker1
    HostName 10.21.163.77
    User root
    Port 8033

Host docker2
    HostName 10.21.163.77
    User root
    Port 8034
```

```mermaid
graph TB
    subgraph Windows
        ssh[SSH client]
    end

    subgraph UbuntuHost["Ubuntu host (10.21.163.77)"]
        port1[Port 8033]
        port2[Port 8034]
    end

    subgraph DockerContainers
        container1[Container 1 - port 22]
        container2[Container 2 - port 22]
    end

    ssh -->|ssh docker1| port1
    ssh -->|ssh docker2| port2
    port1 --> container1
    port2 --> container2

```

## Wrap-Up

glibc compatibility problems on old systems do not always have to be solved by upgrading the host. As long as the host can run Docker reliably, you can move the development environment into a container, then connect it back to your local workflow through port mapping and SSH config.

This is not the "purest" setup: running SSH inside a container, allowing root login, and exposing mapped ports all require care around security boundaries. But when you cannot freely upgrade the remote host and still want to use newer development tools, it is a direct and easy-to-rollback compromise.
