VS Code Tunnel in a Rootless Podman Container: A Complete Setup Guide

9 min read, Sat, 14 Mar 2026

Image from pixabay AI generated image.

Remote Dev Nirvana with Podman, Quadlet, and VS Code Tunnels

Commands, Findings & Lessons Learned


Table of Contents

  1. Overview
  2. Final Containerfile
  3. Systemd Quadlet Configuration
  4. Directory Structure
  5. Setup Commands (Fresh Install)
  6. Day-to-Day Commands
  7. Findings & Lessons Learned
  8. Re-authentication
  9. What is Podman Quadlet?

1. Overview

Setting up a remote development environment often feels like a trade-off between host cleanliness and ease of access. You want the power of your home server or a beefy cloud VM, but you don’t necessarily want to clutter the host OS with language runtimes, VS Code binaries, and various dependencies.

In this guide, we’ll build a completely encapsulated VS Code Tunnel environment using rootless Podman. By the end, you’ll have a Rust and Node.js dev environment that starts automatically on boot via systemd (Quadlet) and is accessible from any browser via vscode.dev

Goals achieved:

The Strategy: Why Podman + Quadlet?

We aren’t just running a container; we are treating it like a first-class system service.


2. Final Containerfile

FROM docker.io/library/node:20-slim

USER root

RUN apt-get update && apt-get install -y \
    curl ca-certificates tar \
    gnome-keyring libsecret-1-0 dbus-x11 \
    git \
    build-essential \
    pkg-config \
    libssl-dev \
    && rm -rf /var/lib/apt/lists/*

# Install VS Code CLI (alpine binary — statically linked, works on Debian)
RUN curl -Lk 'https://code.visualstudio.com/sha/download?build=stable&os=cli-alpine-x64' \
    --output vscode_cli.tar.gz \
    && tar -xf vscode_cli.tar.gz -C /usr/local/bin \
    && rm vscode_cli.tar.gz \
    && chmod +x /usr/local/bin/code

# Install Rust as node user
USER node
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path
ENV PATH="/home/node/.cargo/bin:${PATH}"

# Install wasm-pack and wasm32 target
RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
RUN /home/node/.cargo/bin/rustup target add wasm32-unknown-unknown

USER root
# Fix workspace directory ownership
RUN mkdir -p /home/node/app && chown -R node:node /home/node/app

USER node
WORKDIR /home/node/app
ENTRYPOINT []

CMD ["sh", "-c", "\
  rm -f /home/node/.vscode/cli/tunnel-stable.lock && \
  eval $(dbus-launch --sh-syntax) && \
  echo '' | gnome-keyring-daemon --unlock --components=secrets && \
  code tunnel --accept-server-license-terms --no-sleep --name ${TUNNEL_NAME:-my-container} \
"]

3. Systemd Quadlet Configuration

Quadlet files live in ~/.config/containers/systemd/ and are automatically converted to systemd services by Podman’s generator. No manual systemctl enable is needed — the WantedBy directive is handled automatically.

~/.config/containers/systemd/vscode-tunnel.container

[Unit]
Description=VS Code Tunnel
After=network-online.target
Wants=network-online.target

[Container]
Image=localhost/vscode-tunnel:latest
HostName=vscode-tunnel
Environment=TUNNEL_NAME=vb
Environment=VSCODE_CLI_USE_FILE_KEYCHAIN=1
Environment=VSCODE_CLI_DISABLE_KEYCHAIN_ENCRYPT=1
Volume=/home/vince/podman_container_services/vscode-tunnel/data:/home/node

[Service]
Restart=always
RestartSec=10

[Install]
WantedBy=default.target

4. Directory Structure

~/podman_container_services/vscode-tunnel/
├── Containerfile
├── data/                    ← bind-mounted to /home/node inside container
│   ├── .vscode/
│   │   └── cli/
│   │       ├── token.json   ← auth token (GitHub)
│   │       ├── servers/     ← VS Code server binary (auto-downloaded)
│   │       └── tunnel-stable.lock
│   └── app/                 ← your workspace
└── scripts/
    └── setup.sh

5. Setup Commands (Fresh Install)

5.1 Build the image

# Build the container image
podman build -t vscode-tunnel:latest .

# Verify the image was built
podman images

5.2 Prepare persistent data directory

# Create the host directory for persistence
mkdir -p ~/podman_container_services/vscode-tunnel/data

# Fix ownership for rootless Podman (node user = UID 1000 inside container)
podman unshare chown -R 1000:1000 ~/podman_container_services/vscode-tunnel/data

5.3 First-time interactive authentication

Run interactively with the volume mounted so the auth token is saved to the host:

podman run -it --rm \
  --hostname vscode-tunnel \
  -e TUNNEL_NAME=vb \
  -e VSCODE_CLI_USE_FILE_KEYCHAIN=1 \
  -e VSCODE_CLI_DISABLE_KEYCHAIN_ENCRYPT=1 \
  -v ~/podman_container_services/vscode-tunnel/data:/home/node \
  vscode-tunnel:latest

After authenticating, wait for the tunnel URL:

Open this link in your browser: https://vscode.dev/tunnel/vb/home/node/app

Then press Ctrl+C.

5.4 Install and start the systemd service

# Create the Quadlet directory
mkdir -p ~/.config/containers/systemd

# Copy the Quadlet unit file
cp vscode-tunnel.container ~/.config/containers/systemd/

# Reload systemd so Podman generator picks up the Quadlet
systemctl --user daemon-reload

# Start the tunnel service
systemctl --user start vscode-tunnel.service

# Follow logs to confirm it connected
journalctl --user -u vscode-tunnel -f

# Enable start at boot even without a login session
loginctl enable-linger $USER

6. Day-to-Day Commands

CommandPurpose
systemctl --user status vscode-tunnelCheck if the tunnel is running
journalctl --user -u vscode-tunnel -fFollow live logs
systemctl --user restart vscode-tunnelRestart the tunnel
systemctl --user stop vscode-tunnelStop the tunnel
systemctl --user disable vscode-tunnelDisable auto-start on boot
podman exec systemd-vscode-tunnel ls /home/nodeInspect files inside the running container
podman ps -aList all containers (running and stopped)
podman imagesList all container images
podman volume lsList all Podman volumes
podman rm -f <name>Force remove a container
podman rmi -f <image>Force remove an image
history | grep podmanSearch command history
Ctrl+RReverse search through bash history

7. Findings & Lessons Learned

7.1 CLI Binary — alpine vs linux

The cli-alpine-x64 binary (musl) works on Debian-based node:20-slim because the VS Code CLI is statically linked. No musl compatibility layer needed.

The VS Code download URL os= parameter may be ignored by some corporate proxies — the binary served may differ from what was requested. Always verify with code --version.


7.2 VS Code Server Auto-Download

There is no separate code server install command. The code tunnel command automatically downloads the VS Code server on first run into ~/.vscode/cli/servers/.

The server download happens at runtime, not build time. Ensure the container has internet access on first start. The download is ~100MB and is cached in the bind-mounted data directory.


7.3 Authentication Provider

Never run code tunnel user login --provider microsoft in the CMD — it forces a new auth flow on every container start. Let the tunnel reuse the existing token.json automatically.


7.4 Podman Quadlet Volume Naming (Critical Bug)

Quadlet auto-prefixes systemd- to volume names. A volume referenced as vscode-tunnel-data.volume in the .container file gets created as systemd-vscode-tunnel-data, not vscode-tunnel-data. This caused persistent auth failures because the wrong (empty) volume was being mounted.

Fix — use an absolute host path bind mount instead of a named volume:

# CORRECT — absolute path bind mount
Volume=/home/vince/podman_container_services/vscode-tunnel/data:/home/node

# WRONG — quadlet will prefix systemd- to the volume name
Volume=vscode-tunnel-data.volume:/home/node

You can verify which volume is actually mounted with:

podman inspect systemd-vscode-tunnel | grep -A8 "Mounts"

7.5 Singleton Lock File

The VS Code tunnel CLI creates a tunnel-stable.lock file to prevent multiple instances. If the container exits uncleanly, the lock file persists on the mounted volume and causes:

error access singleton, retrying: could not connect to socket/pipe

Fix — remove the lock file at container startup in CMD:

rm -f /home/node/.vscode/cli/tunnel-stable.lock

7.6 Keyring in Containers

Without a keyring daemon, the VS Code CLI stores the auth token in memory only — it is lost when the container exits.

Fix — install gnome-keyring and launch it at startup:

Required packages:

gnome-keyring libsecret-1-0 dbus-x11

Required env vars:

VSCODE_CLI_USE_FILE_KEYCHAIN=1
VSCODE_CLI_DISABLE_KEYCHAIN_ENCRYPT=1

CMD startup sequence:

eval $(dbus-launch --sh-syntax)
echo '' | gnome-keyring-daemon --unlock --components=secrets

7.7 Workspace File Permissions (EACCES)

The app directory created by WORKDIR in the Containerfile is owned by root when the container runs as the node user. This causes EACCES: permission denied errors when creating files via vscode.dev.

Fix — explicitly chown in the Containerfile:

RUN mkdir -p /home/node/app && chown -R node:node /home/node/app

7.8 Quadlet Units Cannot Be systemctl enable-d

Quadlet-generated units cannot be enabled with systemctl --user enable — they are transient/generated units and the command returns:

Failed to enable unit: Unit is transient or generated

Auto-start is configured via WantedBy=default.target in the [Install] section of the .container file, which the Podman generator handles automatically by creating a default.target.wants/ symlink.


7.9 SSH Lag — WiFi Power Saving

SSH lag from Mac to Ubuntu server was caused by the Ubuntu laptop’s WiFi power saving mode, not SSH configuration. Diagnosis: when Ubuntu was on ethernet SSH was fast; when Ubuntu was on WiFi SSH lagged regardless of what the Mac was on.

Fix — disable WiFi power saving permanently:

# Temporary (until reboot)
sudo iwconfig wlan0 power off

# Permanent
sudo nano /etc/NetworkManager/conf.d/wifi-powersave-off.conf
[connection]
wifi.powersave = 2
sudo systemctl restart NetworkManager

8. Re-authentication (If Token Expires)

# Stop the service
systemctl --user stop vscode-tunnel.service

# Remove the token and lock file
rm -f ~/podman_container_services/vscode-tunnel/data/.vscode/cli/token.json
rm -f ~/podman_container_services/vscode-tunnel/data/.vscode/cli/tunnel-stable.lock

# Run interactively to re-authenticate — choose GitHub
podman run -it --rm \
  --hostname vscode-tunnel \
  -e TUNNEL_NAME=vb \
  -e VSCODE_CLI_USE_FILE_KEYCHAIN=1 \
  -e VSCODE_CLI_DISABLE_KEYCHAIN_ENCRYPT=1 \
  -v ~/podman_container_services/vscode-tunnel/data:/home/node \
  vscode-tunnel:latest

# Ctrl+C after tunnel URL appears, then restart the service
systemctl --user start vscode-tunnel.service
journalctl --user -u vscode-tunnel -f

9. What is Podman Quadlet?

If you’ve ever tried to manage containers on Linux, you’ve likely faced a dilemma: do you use docker-compose (which requires a daemon) or manual systemd unit files (which are incredibly verbose and hard to write for containers)?

Podman Quadlet is the “Goldilocks” solution. It allows you to describe a container in a simple, declarative format that systemd understands natively.

How the Magic Happens (The Generator)

Quadlet isn’t a background service; it’s a systemd generator.

  1. You write a simple .container file.
  2. When you run systemctl --user daemon-reload, a specific Podman binary scans your directory.
  3. It “transpiles” your simple file into a full-blown, complex systemd service file located in a temporary runtime directory (/run/user/1000/...).

This means you get all the enterprise-grade features of systemd—like dependency management (waiting for the network to be up) and automatic restarts—without having to learn the complex syntax of ExecStart and Type=notify for containers.

The Quadlet File Ecosystem

While we only used a .container file for this project, Quadlet supports a full suite of infrastructure-as-code files:

ExtensionPurpose
.containerDefines a container (like a docker-compose service)
.volumeDefines a named Podman volume
.networkDefines a container network
.podDefines a pod grouping multiple containers

Where to Put Your Files

The location of your Quadlet files determines the “owner” of the process:

Podman’s systemd generator at /usr/lib/systemd/user-generators/podman-user-generator converts them into proper systemd services at /run/user/1000/systemd/generator/ on every daemon-reload.

Why Quadlet over alternatives:

ApproachStatusIssue
podman run in ExecStartWorksVerbose, fragile
podman generate systemdDeprecatedMessy output
Quadlet .containerRecommendedClean, declarative, version-controllable

Quadlet integrates cleanly with systemd lifecycle management — auto-restart, boot start, and journalctl logging all work out of the box.

Consclusion

You now have a portable, self-healing development environment. Whether you are on a Chromebook in a coffee shop or a tablet on a train, your full Rust/Node stack is just a browser tab away.