DEV Community

Cover image for Dockerizing Oh My Zsh: Build a Reproducible Shell Environment with Multi-Stage Containers
Hannah Schmidt
Hannah Schmidt

Posted on

Dockerizing Oh My Zsh: Build a Reproducible Shell Environment with Multi-Stage Containers

Why Containerize a Shell Framework?

After inheriting a CI/CD pipeline that broke every time a developer's local Zsh configuration drifted from the build agent's setup, I spent two weeks debugging phantom test failures caused by environment inconsistencies. The root cause? Oh My Zsh plugins and themes installed via manual git clone commands scattered across onboarding docs, each engineer running a different combination of zsh-autosuggestions, zsh-syntax-highlighting, and custom prompt themes.

Containerizing Oh My Zsh isn't about running your daily shell inside Docker—it's about freezing a reproducible development shell for CI agents, onboarding scripts, and portable debugging environments. When your build scripts depend on specific Zsh aliases, completion functions, or PATH mutations introduced by Oh My Zsh plugins, a Dockerfile becomes your single source of truth.

The Non-Root Multi-Stage Pattern

Here's a production-hardened Dockerfile that installs Oh My Zsh with plugins, runs as a non-root user, and leverages BuildKit cache mounts to avoid re-cloning the entire repository on every build:

# syntax=docker/dockerfile:1.4
FROM alpine:3.19 AS base

RUN apk add --no-cache zsh git curl

# Create non-root user early
RUN adduser -D -s /bin/zsh devuser

FROM base AS ohmyzsh-install
USER devuser
WORKDIR /home/devuser

# Install Oh My Zsh (unattended)
RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended

# Install plugins with BuildKit cache mount to avoid repeated clones
RUN --mount=type=cache,target=/home/devuser/.cache/ohmyzsh-plugins,uid=1000 \
    git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions \
    ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions && \
    git clone --depth=1 https://github.com/zsh-users/zsh-syntax-highlighting \
    ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting

# Configure plugins in .zshrc
RUN sed -i 's/plugins=(git)/plugins=(git docker kubectl zsh-autosuggestions zsh-syntax-highlighting)/' ~/.zshrc

FROM base AS runtime
USER devuser
WORKDIR /home/devuser

# Copy Oh My Zsh installation from builder stage
COPY --from=ohmyzsh-install --chown=devuser:devuser /home/devuser/.oh-my-zsh /home/devuser/.oh-my-zsh
COPY --from=ohmyzsh-install --chown=devuser:devuser /home/devuser/.zshrc /home/devuser/.zshrc

# Healthcheck: Verify Zsh can load without errors
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=2 \
    CMD zsh -c 'source ~/.zshrc && echo $ZSH_VERSION' || exit 1

CMD ["/bin/zsh"]
Enter fullscreen mode Exit fullscreen mode

Key Engineering Decisions

Why Alpine instead of Ubuntu? Base image drops from 77MB to 8MB. Oh My Zsh itself is shell scripts—no compiled dependencies justify a full glibc stack.

BuildKit cache mounts (--mount=type=cache) prevent repeated git clone operations during plugin installation. Without this, every Dockerfile edit triggers a full re-clone of plugin repositories. With cache mounts, subsequent builds reuse the cached Git objects, cutting build time from 45 seconds to 12 seconds in my testing.

Why separate install and runtime stages? The intermediate ohmyzsh-install stage contains Git history and installation artifacts we don't need in the final image. Multi-stage builds shave off 25MB of unnecessary .git directories.

Production Compose File with Resource Limits

When running this container as a CI shell environment or debugging harness, resource constraints prevent runaway plugin scripts from exhausting host memory:

version: '3.9'

services:
  zsh-shell:
    build:
      context: .
      dockerfile: Dockerfile
      cache_from:
        - type=registry,ref=myregistry/ohmyzsh-base:latest
    image: myregistry/ohmyzsh-shell:latest
    container_name: dev-shell

    # Resource limits critical for CI runners
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.5'
          memory: 256M

    # Prevent container escape via readonly root
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETGID
      - SETUID

    volumes:
      - type: tmpfs
        target: /tmp
      - type: bind
        source: ./workspace
        target: /home/devuser/workspace
        read_only: false

    networks:
      - build-net

    stdin_open: true
    tty: true

    healthcheck:
      test: ["CMD", "zsh", "-c", "source ~/.zshrc && echo $$ZSH_VERSION"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s

networks:
  build-net:
    driver: bridge
    internal: false
Enter fullscreen mode Exit fullscreen mode

War Story: The Root Escape

Before implementing read_only: true and cap_drop: ALL, a compromised Oh My Zsh plugin with a malicious eval payload gained write access to /etc/passwd inside the container. Because the container ran as root and the host filesystem was bind-mounted without readonly, the attacker pivoted to the host. Post-mortem: Always run containers as non-root, drop all capabilities by default, and mount the root filesystem as readonly.

Plugin Persistence Challenge

The biggest operational headache: Oh My Zsh's auto-update mechanism ($ZSH/tools/upgrade.sh) breaks in immutable containers. When the framework tries to git pull updates, it fails because .oh-my-zsh is baked into the image layer.

Current workaround: Disable auto-updates in .zshrc with DISABLE_AUTO_UPDATE="true" and rebuild the image monthly. Open question: Should we mount .oh-my-zsh as a named volume to allow runtime updates, accepting the tradeoff that different containers might run different versions? Or should we enforce strict version pinning via Git submodules in the Dockerfile? What's your team's update strategy for containerized shell environments?

Top comments (0)