Containing Malware in a Container
There is no escape from malware being constantly published to npm or pypi. Every npm install is a gamble.
Here is my attempt at moving my entire development setup to a container. This works well if you use tmux and neovim. The goal: isolate untrusted code from the host while keeping a comfortable dev experience.
How It Works
The setup uses three files that work together:
dev.sh (orchestrator)
│
├─► Builds image from Dockerfile (base OS + tools)
│
├─► Runs setup.sh inside container (configures dotfiles, mise, nvim plugins etc.)
│
├─► Commits the result (so setup only runs once)
│
└─► Launches tmux in a fresh container from the committed image
- Isolated:
~/.ssh, browser data, credentials, everything outside the project - Shared: the
~/devdirectory (mounted read-write)
1. Dockerfile: The Base Image
This builds a minimal Fedora image with core dev tools.
FROM fedora:latest
# Install core tools
RUN dnf install -y \
neovim \
tmux \
git \
curl \
wget \
gcc \
gcc-c++ \
make \
ripgrep \
fd-find \
fzf \
openssh-clients \
ca-certificates \
sudo \
glibc-langpack-en \
the_silver_searcher \
ncdu \
btop \
&& dnf clean all
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV TERM=xterm-256color
ENV COLORTERM=truecolor
ARG USERNAME=amin
ARG USER_UID=1000
ARG USER_GID=1000
RUN groupadd --gid ${USER_GID} ${USERNAME} \
&& useradd --uid ${USER_UID} --gid ${USER_GID} -m ${USERNAME} \
&& echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${USERNAME} \
&& chmod 0440 /etc/sudoers.d/${USERNAME}
COPY setup.sh /tmp/setup.sh
USER ${USERNAME}
WORKDIR /home/${USERNAME}
Notes:
- The user creation matches the host UID/GID so mounted files have correct permissions
sudois passwordless for convenience
2. setup.sh: Personal Environment Setup
This script runs once inside the container to set up the dotfiles and tools.
#!/bin/bash
readonly GITHUB_REPO="your-username/dot-files-repo"
# Clone dotfiles using SSH (agent forwarded from host)
git clone \
-c core.sshCommand="ssh -o StrictHostKeyChecking=no" \
git@github.com:${GITHUB_REPO}.git \
&& mv dot-files/.git . \
&& git checkout -- . \
&& rm -rf dot-files
# Install mise (manages node, python, etc.)
curl https://mise.run | sh
# Install uv (fast Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh
source ~/.bashrc
# Install language runtimes via mise
mise use -g node@latest
mise use -g bun@latest
# Install direnv for per-project env vars
curl -sfL https://direnv.net/install.sh | bash
# Install neovim plugins
nvim --headless "+Lazy! sync" +qa
git config --global user.email "your-email@example.com"
git config --global user.name "your name"
3. dev.sh: The Orchestrator
It handles SSH agent forwarding (different on macOS vs Linux) and the build-setup-commit flow.
#!/usr/bin/env bash
set -euo pipefail
readonly IMAGE_NAME="dev"
readonly VM_SOCKET="/tmp/ssh-agent.sock"
ssh_tunnel_pid=""
cleanup() {
if [[ -n "$ssh_tunnel_pid" ]]; then
kill "$ssh_tunnel_pid" 2>/dev/null || true
fi
}
### SSH Agent Forwarding
# SSH keys stay on the host, but the container can use them.
# macOS runs containers in a Linux VM, so we tunnel the socket through:
setup_macos() {
# Forward host's SSH agent socket into the Podman VM
podman machine ssh -- -R "${VM_SOCKET}:${SSH_AUTH_SOCK}" -N &
ssh_tunnel_pid=$!
sleep 1
podman machine ssh -- chmod 777 "$VM_SOCKET"
run_opts+=(
-v "${VM_SOCKET}:${VM_SOCKET}"
-e "SSH_AUTH_SOCK=${VM_SOCKET}"
)
}
# Linux can mount the socket directly:
setup_linux() {
local socket_dir="/run/user/$(id -u)"
run_opts+=(
-v "${socket_dir}:${socket_dir}"
-e "SSH_AUTH_SOCK=${SSH_AUTH_SOCK}"
--network=host
--ulimit=host
)
}
### Build, Setup, and Commit
main() {
trap cleanup EXIT
# Build the base image from Dockerfile
podman build -t "${IMAGE_NAME}:latest" .
declare -a run_opts=(
--replace
-it
--detach-keys="ctrl-@"
--name "${IMAGE_NAME}-temp"
--userns=keep-id # Maps container UID to your host UID
--security-opt label=disable
)
case "$(uname)" in
Darwin) setup_macos ;;
*) setup_linux ;;
esac
# Run setup.sh and commit the result
podman run "${run_opts[@]}" "${IMAGE_NAME}:latest" /tmp/setup.sh
podman commit "${IMAGE_NAME}-temp" "${IMAGE_NAME}:updated"
### Launch the Dev Environment
# Fresh options for the final container
run_opts=(
--rm # Delete container on exit
-it
--detach-keys="ctrl-@"
--userns=keep-id
--security-opt label=disable
)
case "$(uname)" in
Darwin) setup_macos ;;
*) setup_linux ;;
esac
# Mount ONLY the project(s) directory - nothing else
run_opts+=(-v "${HOME}/dev:/home/amin/dev")
podman run "${run_opts[@]}" "${IMAGE_NAME}:updated" tmux
}
main "$@"
--userns=keep-id: The host UID maps to the container user, so file permissions work correctly on mounted volumes--security-opt label=disable: Disables SELinux labeling (avoids permission issues with mounts)--detach-keys="ctrl-@": Changes the detach sequence fromctrl-p ctrl-q(conflicts with tmux)
Why Podman?
I’m using podman instead of Docker because it runs rootless by default.