June 1, 2026·13 min read

Building a Remote Dev Environment on an iPad

How I replaced two laptops with an iPad using isolated dev containers — accessible via Mosh, VS Code in the browser, and custom Home Screen icons.

I run personal projects and work out of separate contexts. Like most people, I kept those contexts on separate laptops — different machines, different accounts, different repos, different auth. Clean and simple.

The goal was to replace both of those laptops with an iPad. Turns out going from two laptops to one iPad takes a lot of engineering. Who knew.

This is the full writeup: the why, the architecture, how everything is provisioned, how I work from it day-to-day, and a section at the end about a custom icon problem that I spent an embarrassing amount of time on, but will be documented here anyway.

Why a remote dev environment

The requirements were:

  • Completely isolated environments for each context — separate repos, separate credentials, separate Claude Code session history
  • Full terminal access, with my exact shell setup (zsh, Oh My Zsh, tmux, vim, all my aliases and keybindings)
  • VS Code in the browser with Claude Code, since that's my primary AI coding setup
  • Accessible from an iPad, either via SSH/terminal or via a browser
  • Persistent — sessions survive network changes, sleep, switching between apps

I work across personal and work contexts and want to keep them completely separate — the infrastructure enforces that rather than relying on being careful.

The setup

A dedicated Ubuntu VM on my homelab running Docker containers, one per context — personal and work.

Each container is its own isolated environment with its own:

  • SSH server
  • code-server (VS Code in the browser)
  • Claude Code CLI
  • Home directory on a persistent Docker volume (repos, dotfiles, credentials, shell history, Claude Code session history all survive container rebuilds)
  • Cloud CLI toolchain — each context gets only what it needs

The VM host runs Caddy as a reverse proxy, plus Mosh for persistent terminal sessions.

Provisioning: Ansible from scratch

Everything is provisioned with Ansible. The full main.yml run on a fresh VM does, in order: OS upgrade, sudo config, timezone, packages, SSH hardening, key sync, MOTD, dotfiles, Docker, Mosh, fail2ban, TLS, dev containers, git key, Caddy, then a restart.

The heavy task is devcontainer. It:

  1. Copies the entire lab compose project to the VM
  2. Builds lab-base:latest — the shared base image
  3. Runs docker compose build to create per-context images on top of the base
  4. Brings everything up with docker compose up -d
  5. Waits for all containers to be healthy, then syncs SSH keys into each

On a fresh VM: ansible-playbook -i hosts main.yml -e "host=[server]" and come back in about 15 minutes.

The project structure on the VM

~/lab/
├── base.Dockerfile          # Shared base image (lab-base:latest)
├── docker-compose.yml       # All contexts, named volumes
├── entrypoint.sh            # Parameterized by SSH_PORT, CS_PORT, CONTEXT_NAME
├── code-server-setup.sh     # Extensions, settings, keybindings (idempotent)
├── zshrc                    # Context-aware shell config
├── .env.personal            # PASSWORD=... (gitignored)
├── .env.work                # PASSWORD=...
├── caddy/
│   ├── Caddyfile            # Reverse proxy + landing page routing
│   └── landing/             # Static landing pages + custom icons
└── contexts/
    ├── personal/
    │   ├── Dockerfile       # FROM lab-base:latest, adds context-specific tools
    │   └── context.env      # CONTEXT_NAME, ports, dev port range
    └── work/
        ├── Dockerfile
        └── context.env

What's in the base image

The lab-base:latest image is shared across all contexts. It includes:

  • Ubuntu 24.04, git, curl, vim, tmux, htop, jq, fzf, ripgrep, bat, ranger, and the rest of the standard toolkit
  • Node.js LTS, Python 3, build-essential
  • GitHub CLI
  • Docker CLI (no daemon — it talks to the host socket via bind mount)
  • code-server (VS Code in the browser)
  • Oh My Zsh with three plugins: zsh-autosuggestions, zsh-syntax-highlighting, zsh-history-substring-search
  • A custom zshrc, tmux config, vimrc, and about 50 shell aliases
  • Claude Code VSIX (pinned version — more on this in a separate post)

Per-context Dockerfiles add only what that context needs on top of the base.

Shell environment: fully programmatic

This is one of my favorite parts. The terminal setup inside each container is a faithful port of my Mac terminal stack, completely provisioned from code.

Source of truth

Everything lives in vars/configs/lab/shell/ in the Ansible repo:

shell/
├── zshrc                    # Oh My Zsh, lambda theme, context-aware prompt
├── aliases.zsh              # ~50 curated aliases
├── functions.zsh            # mkcd, extract, gitclone-cd, etc.
├── vimrc                    # vim-plug + 8 plugins
├── vim-plug-install.sh      # bootstrapper
├── tmux.conf                # vendored gpakosz/oh-my-tmux
├── tmux.conf.local          # prefix C-a, OSC52 clipboard, etc.
└── motd/
    └── lab-motd.sh          # per-context MOTD with ASCII banner, resources, auth status

Each context can also have overrides at contexts/<name>/overrides/aliases.zsh and env.zsh — context-specific aliases, environment variables, and directory shortcuts.

How it lands in the container

During docker build, the shell/ tree is copied into /opt/lab-shell/ inside the image. On every container start, entrypoint.sh runs and:

  1. Copies zshrc, vimrc, tmux.conf, tmux.conf.local into ~/ only if they don't already exist. Local edits stick across rebuilds.
  2. Always refreshes ~/.config/lab/aliases.zsh and ~/.config/lab/env.zsh from the per-context overrides. These are repo-managed.
  3. Bootstraps vim-plug and installs all plugins on first boot.
  4. Installs the MOTD.

Three layers of customization:

  • ~/.zshrc etc. — per-container local tweaks, survive rebuilds via the home volume, not in git
  • ~/.config/lab/aliases.local.zsh / env.local.zsh — local-only secrets and aliases, never tracked
  • vars/configs/lab/shell/ — shared across all contexts, in git, redeployed via Ansible

When you want to add an alias everywhere: edit shell/aliases.zsh in the repo and redeploy. When you want a context-specific alias: edit contexts/<name>/overrides/aliases.zsh. When you want a one-shot thing just for you: edit ~/.zshrc inside the container.

MOTD

Every login prints a context-branded MOTD: the context name in its own color (cyan for personal, etc.), a dev container block showing ports and git status of active repos, a resources block with CPU/memory/disk, and an auth block showing which cloud credentials are active. Auth status is cached for five minutes so the MOTD doesn't add latency to every shell open.

Accessing it from the iPad

Two ways in: terminal via Blink Shell, and VS Code in Safari.

Terminal: Mosh vs SSH

Blink Shell is the terminal app. Each container is configured as a separate Blink host, all pointing at the same VM IP but on different SSH ports.

[user]@[server] -p [port]    # personal context
[user]@[server] -p [port]    # work context

I use Mosh (Mobile Shell) instead of plain SSH for terminal sessions. The difference matters a lot on mobile:

  • SSH uses TCP. When the iPad sleeps, switches Wi-Fi networks, or switches from Wi-Fi to cellular, the TCP connection drops. You come back to a dead terminal and have to reconnect.
  • Mosh uses UDP with a roaming protocol. It doesn't care about the underlying network changing — it just keeps the session alive. When you switch back to Blink after the iPad has been asleep or has changed networks, the session is exactly where you left it. tmux auto-attaches to the default session on each login, so every context has a persistent workspace with split panes, running processes, and history — exactly as you left it.

Mosh runs on the VM host (not inside the containers themselves). The containers expose SSH; Mosh proxies through.

Inside each container, the prompt shows the active context in its own color so you always know where you are. The shell prints the dev server port range on startup — a soft guardrail for when all three contexts share the VM's network stack.

Auth: device-code flows for everything

This was a non-trivial problem. Remote terminals can't complete localhost OAuth redirects. All the standard gcloud auth login, gh auth login, aws sso login flows try to open a browser and redirect back to localhost, which doesn't exist in a terminal-only context.

The solution is lab-auth, a helper that wraps everything in device-code flows — the RFC 8628 pattern where you get a code, open a URL on your iPad, enter the code, and the terminal polls until you approve. Every tool supports this; it's just not always the default.

lab-auth status    # see what's not authenticated
lab-auth gh        # GitHub: device code flow
lab-auth claude    # Claude Code: paste-back verification
lab-auth aws       # AWS SSO: device code
lab-auth gcp       # GCP: URL + paste-back
lab-auth azure     # Azure: device code

Credentials live in each container's home volume and persist across rebuilds. Each context has completely separate ~/.config/gcloud, ~/.aws, ~/.azure, ~/.gitconfig, and ~/.claude/ directories. There's no way to accidentally use work credentials in the personal container.

The git SSH key is the one thing that's shared — a single key is mirrored into all containers so git clone git@github.com:... works from any context.

VS Code: code-server in the browser

code-server (VS Code in a browser) runs inside each container on a different port. Caddy on the VM host reverse-proxies all of them to named hostnames with a real Let's Encrypt wildcard TLS certificate, obtained via DNS-01 challenge against Route 53 — no ports exposed publicly, no certificate warnings, real HTTPS over WireGuard when remote.

The Claude Code VS Code extension runs in code-server. There were two upstream bugs to fix before it worked on Safari/iPad — that's a full separate post here. Short version: a version pin plus a one-byte patch to a minified bundle.

The Home Screen icon saga

This is the part I'm slightly embarrassed about in terms of how long it took, but genuinely proud of in terms of the result.

How it started

The initial setup had code-server running behind Caddy with a custom hostname per context. The natural first move: open the URL in Safari, tap Share → Add to Home Screen, and tap the icon to go straight to VS Code. Clean.

Except the icon was just an auto-generated screenshot of the page. On iOS, if a site doesn't declare an apple-touch-icon, Safari generates one from the page content. They all looked identical and vaguely confusing. Not acceptable.

Attempt 1: iOS Shortcuts

One obvious approach: use iOS Shortcuts to create shortcuts with custom icons. You can assign any icon you want to an iOS shortcut. The shortcut just opens a URL.

The problem: iOS Shortcuts-generated Home Screen icons don't behave the same as proper web app shortcuts. They open in Safari (full browser UI, with address bar), not in standalone mode. And there was no way to pass a ?folder= query parameter through the shortcut URL in a way that code-server would act on while also having a custom icon that matched the context. The shortcut and the "Add to Home Screen" flows are separate iOS mechanisms with different behaviors and you can't combine them the way you want.

Multiple dead ends here. At one point I was trying to chain shortcut actions to do a file open, which obviously didn't work. This was a dark period.

Attempt 2: Separate contexts + proper PWA declaration

The actual solution came from understanding how iOS decides what icon to show for a Home Screen web app.

When you tap Share → Add to Home Screen in Safari, iOS looks for a <link rel="apple-touch-icon" href="..."> in the page's <head>. If it finds one, it uses that image. If not, it screenshots the page. So the fix is: serve a page that declares a custom apple-touch-icon.

But there's a catch. The URL I wanted on the Home Screen was https://personal.lab.example.com/?folder=/home/[user]/apps/personal — i.e., code-server opening directly in a specific directory. If I visited that URL in Safari to add it to Home Screen, it would load code-server immediately and the Add to Home Screen gesture would produce a screenshot of the VS Code UI — still no custom icon.

The solution is a landing page — a separate page that:

  1. Shows a friendly instruction page in Safari (no redirect), so you have time to tap Share → Add to Home Screen
  2. Declares the custom apple-touch-icon so iOS picks up the right image
  3. Detects when it's being launched as a Home Screen PWA (standalone mode) and immediately redirects to code-server

The detection is one line of JavaScript:

if (window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches) {
    window.location.replace("/?folder=/home/[user]/apps/personal");
}

In Safari: the standalone check is false, the page renders the instructions, you tap Add to Home Screen, iOS uses the declared icon.

From the Home Screen: the standalone check is true, the page redirects immediately to code-server in the right workspace.

The full Caddy setup

Caddy serves the landing page at / (when there's no ?folder= query) and proxies everything else to code-server. The routing in the Caddyfile:

personal.lab.[domain]:
  - GET / with no ?folder= → serve landing/personal.html (instructions + apple-touch-icon)
  - GET /personal.png → serve the icon file directly (so apple-touch-icon src resolves)
  - everything else → reverse proxy to code-server :8080

The icon PNG file also needs a direct route — iOS Safari fetches the apple-touch-icon href as a separate request, which would otherwise fall through to the code-server proxy and 404. That took a while to figure out. Without it, Safari falls back to a letter-based placeholder icon on the Home Screen (the first letter of the title, styled nicely, which is fine but not the point).

Each context gets its own landing page and its own icon. The landing pages live in the Ansible repo at vars/configs/lab/caddy/landing/ and are deployed with the Caddy task.

Adding to Home Screen (the right process)

  1. Open the landing URL in Safari (not from a code-server URL directly)
  2. The page shows a blue instructions screen — it does not redirect in a normal browser
  3. Tap Share → Add to Home Screen
  4. Safari shows a preview with the custom icon
  5. Set the name and tap Add

From now on, tapping the icon opens standalone, detects standalone mode, and redirects directly to the right workspace. No browser chrome, no address bar, full-screen VS Code.

Multiple separate shortcuts on the Home Screen, each with a different custom icon, each opening directly into the right container and workspace. That's the goal. It took the better part of two days to get there.


How it looks in practice

Day-to-day from the iPad:

Terminal:

mosh [server]       # personal context — tmux auto-attaches
claude              # start an AI session in the current directory

Browser: tap the context's Home Screen icon → standalone VS Code loads in about two seconds → Claude Code panel is in the sidebar.

Switching contexts means either switching to a different Blink tab (each Mosh session is independent) or tapping a different Home Screen icon. Both are instant.

When away from home, WireGuard goes on. Everything works the same — same URLs, same ports, same workflow. The entire lab is only reachable on LAN or VPN; nothing is exposed to the public internet.

Rebuilding a context without losing anything:

ssh [user]@[server]     # SSH to VM host
devbox-rebuild personal  # rebuilds and restarts only that container

Home volume, repos, credentials, shell history, and Claude Code session history all survive. The base image layer is cached; only the context layer rebuilds if the base hasn't changed. Usually under two minutes.


The result

Two laptops replaced by one iPad. Each work context fully isolated at the infrastructure level — separate home directories, separate credentials, separate Claude Code threads, separate git identities. The shell environment is identical to what I run on a Mac. Sessions stay alive through network changes and screen sleep. Custom icons on the Home Screen.

Worth it. Would not do it differently.

← All posts