June 16, 2026
Stop Using a VM for HackTheBox. Use Docker Instead.
I’ve been doing HackTheBox for a while now. And for most of that time, my workflow looked like this: boot Kali in VirtualBox, wait, connect…
Gleb Wam
4 min read
I've been doing HackTheBox for a while now. And for most of that time, my workflow looked like this: boot Kali in VirtualBox, wait, connect VPN, wait some more, open five terminals, accidentally close the wrong one, lose context, get annoyed.
The VM wasn't the bottleneck in terms of hacking skill. It was a bottleneck in terms of friction. And friction kills momentum — especially when you're mid-box at midnight and your VM decides it's a great time to freeze.
So I rebuilt my entire CTF setup around a custom Docker image. Six months in, I'm not going back.
This is the full breakdown: why I switched, how the image is built, and the workflow that comes out of it.
The problem with VMs for CTF work
VMs are great for isolation. They're not great for the kind of rapid, iterative work that CTFs demand.
Boot time. Every session starts with 30–60 seconds of waiting. Doesn't sound like much until you've done it 200 times. Docker containers start in under a second.
RAM overhead. A Kali VM with a desktop environment sitting idle eats 2–3 GB of RAM doing nothing. A container uses only what the running tools actually need.
Persistence is awkward. Snapshots work but they're clunky. Shared folders need to be configured and they break. You inevitably end up with files scattered between host and VM with no clear system.
VPN configuration. Getting a bridged network adapter to properly expose tun0 inside a VM is annoying every single time. With --network host, the container inherits your host's network stack directly. Connect OpenVPN on the host, tools inside the container immediately reach HTB machines. Zero config.
Reproducibility. If your VM breaks — and eventually it will — you're rebuilding from memory. A Dockerfile is a complete, version-controlled specification of your environment.
The architecture
The setup is three files: a Dockerfile, a Makefile, and a mounted folder on the host.
your-writeups/
├── Dockerfile
├── Makefile
└── ctfdata/ ← mounted as /ctfdata inside the container
├── .bash_history ← persists across rebuilds
← .target ← current target IP, shows in prompt
├── scans/
├── loot/
├── exploit/
├── notes/
└── vpn/your-writeups/
├── Dockerfile
├── Makefile
└── ctfdata/ ← mounted as /ctfdata inside the container
├── .bash_history ← persists across rebuilds
← .target ← current target IP, shows in prompt
├── scans/
├── loot/
├── exploit/
├── notes/
└── vpn/The key insight: nothing important lives inside the container. Scans, loot, notes, and bash history all go into ctfdata/ on the host. This means the container is fully disposable. Rebuild it, update it, blow it up — your work survives.
What's in the image
Built on kalilinux/kali-rolling. The full tool list, grouped by use case:
Recon & scanning nmap masscan rustscan dnsutils whois tcpdump
Web gobuster ffuf nikto whatweb wfuzz sqlmap
Password attacks hydra john hashcat + rockyou.txt + full SecLists
Windows / Active Directory netexec (CrackMapExec) · evil-winrm · kerbrute · impacket (the full suite — psexec, secretsdump, GetNPUsers, lookupsid, ...) · BloodHound-python · adPEAS · PowerSploit/PowerView · Responder
Post-exploitation & pivoting chisel · ligolo-ng · proxychains4 · socat
PrivEsc linPEAS · winPEAS · pspy64
Exploitation metasploit-framework · pwntools
Misc tmux · binwalk · exiftool · steghide · jq
One thing I was deliberate about: the image installs tools, not a desktop. No GUI, no display server, no 4 GB of packages nobody uses. The image stays lean.
The Makefile
The Makefile is the interface to the whole setup:
make build # Build the image (~15 min first time)
make up # Start container (uses --network host)
make resume # Jump back into a stopped container
make shell # Second shell in the running container
make stop # Pause — work is saved
make new-machine NAME=Forest # Scaffold folder + notes template
make set-target IP=10.10.11.206 # Sets $TARGET in your promptmake build # Build the image (~15 min first time)
make up # Start container (uses --network host)
make resume # Jump back into a stopped container
make shell # Second shell in the running container
make stop # Pause — work is saved
make new-machine NAME=Forest # Scaffold folder + notes template
make set-target IP=10.10.11.206 # Sets $TARGET in your promptmake up runs:
docker run -it \
--name htb-box \
--network host \
--cap-add=NET_ADMIN \
--device=/dev/net/tun \
-v $(PWD)/ctfdata:/ctfdata \
kali-htbdocker run -it \
--name htb-box \
--network host \
--cap-add=NET_ADMIN \
--device=/dev/net/tun \
-v $(PWD)/ctfdata:/ctfdata \
kali-htb--network host is the important flag. The container doesn't get its own network namespace — it uses the host's directly. tun0 is visible inside the container the moment you connect OpenVPN on the host.
Bash history that actually works
Default Docker containers have no persistent history. This is a small thing that's extremely annoying in practice.
The fix is two lines in .bashrc:
export HISTFILE=/ctfdata/.bash_history
PROMPT_COMMAND="history -a; history -c; history -r; ${PROMPT_COMMAND}"export HISTFILE=/ctfdata/.bash_history
PROMPT_COMMAND="history -a; history -c; history -r; ${PROMPT_COMMAND}"History file lives on the host. PROMPT_COMMAND flushes it after every command rather than only on exit. Rebuild the container — your full history is still there.
On top of that, arrow keys search by prefix:
bind '"\e[A": history-search-backward'
bind '"\e[B": history-search-forward'bind '"\e[A": history-search-backward'
bind '"\e[B": history-search-forward'Type nmap and press ↑ — you scroll through only nmap commands. Type evil-winrm and press ↑ — only evil-winrm commands. This alone saves a surprising amount of time during active enumeration.
The Claude Code integration
This is the part that changed my workflow more than I expected.
Because the container uses --network host and shares a filesystem with the host, Claude Code running on the host machine has full visibility into everything. You can pipe tool output directly into Claude mid-session:
# Analyze nmap results without switching context
nmap -sC -sV 10.10.11.206 | claude "what services look interesting here?"
# You found a hash — what type, how to crack it
cat /ctfdata/loot/hash.txt | claude "identify this hash and give me the hashcat mode"
# Gobuster finished — what's worth investigating
cat /ctfdata/scans/gobuster.txt | claude "analyze these web paths, flag anything unusual"
# Stuck on privesc — get a second opinion
cat /ctfdata/loot/linpeas.txt | claude "what privesc vectors stand out in this linpeas output?"# Analyze nmap results without switching context
nmap -sC -sV 10.10.11.206 | claude "what services look interesting here?"
# You found a hash — what type, how to crack it
cat /ctfdata/loot/hash.txt | claude "identify this hash and give me the hashcat mode"
# Gobuster finished — what's worth investigating
cat /ctfdata/scans/gobuster.txt | claude "analyze these web paths, flag anything unusual"
# Stuck on privesc — get a second opinion
cat /ctfdata/loot/linpeas.txt | claude "what privesc vectors stand out in this linpeas output?"This is not AI doing the box for you. It's AI as a fast lookup and analysis layer — the difference between spending 10 minutes googling a service version and spending 30 seconds getting a targeted answer. You're still doing the work. You're just removing the friction of context-switching.
The reason this works specifically with Docker (vs a VM) is filesystem access. Claude Code on the host can read ctfdata/ directly. No shared folder config, no file copying, no port forwarding.
Typical session
# VPN already connected on host
make up
# Inside the container
make set-target IP=10.10.11.206 # sets $TARGET, shows in prompt
# Recon
nmap-quick $TARGET # alias: nmap -T4 --open -oA /ctfdata/scans/quick
nmap-full $TARGET # alias: nmap -sC -sV -p- -oA /ctfdata/scans/full
# Web enum
ff http://$TARGET/ # alias: ffuf with raft-medium wordlist
# Need to stop — just close the terminal
# Container keeps running in background
# Next day — pick up exactly where you left off
make resume
# Full history available, all scans in /ctfdata/scans/# VPN already connected on host
make up
# Inside the container
make set-target IP=10.10.11.206 # sets $TARGET, shows in prompt
# Recon
nmap-quick $TARGET # alias: nmap -T4 --open -oA /ctfdata/scans/quick
nmap-full $TARGET # alias: nmap -sC -sV -p- -oA /ctfdata/scans/full
# Web enum
ff http://$TARGET/ # alias: ffuf with raft-medium wordlist
# Need to stop — just close the terminal
# Container keeps running in background
# Next day — pick up exactly where you left off
make resume
# Full history available, all scans in /ctfdata/scans/Getting started
The full setup is on GitHub: https://github.com/Wamgleb/CTF-Kali-Docker-Image.git
git clone https://github.com/Wamgleb/CTF-Kali-Docker-Image.git
cd kali-htb-docker
make build
make upgit clone https://github.com/Wamgleb/CTF-Kali-Docker-Image.git
cd kali-htb-docker
make build
make upThe Dockerfile is structured to be easy to customize — tools are grouped by category, each in its own RUN block. Add what you use, remove what you don't. The Makefile targets are documented with make help.
What I'd still improve
A few things on the roadmap:
make ai-recon IP=x.x.x.x— run nmap and pipe straight to Claude in one command- GitHub Actions to build and push to GHCR on every commit, so others can
docker pullwithout building locally fzfintegration for fuzzy history search with Ctrl+Rcertipyandcoercerfor AD CS attack paths — increasingly common on HTB
If you end up using this and find something broken or missing, PRs are open.
The repo includes the full Dockerfile, Makefile, and folder structure. MIT licensed.