puck tools — test your egress policy against real C2 traffic →

Tearing it down

Two loose ends. Docker will eat your disk if nobody stops it, and the image you built is stuck on the machine where you built it. Both are five minute problems once you know the commands.

Putting the lab away

$ docker compose down
 Container dfh-lab-kali-1 Removed
 Container dfh-lab-web-dvwa-1 Removed
 Container dfh-lab-juice-shop-1 Removed
 Network dfh-lab_private Removed
 Network dfh-lab_public Removed

Containers and networks gone, images kept, so docker compose up -d tomorrow is instant. Add -v to delete named volumes too, which is how you reset a target that has stored state, and --rmi all to delete the images as well.

Run it from the directory that holds the compose file, because that is what names the project. From anywhere else, docker compose down finds nothing and says so cheerfully.

Where the disk went

$ docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          20        0         10.36GB   8.453GB (81%)
Containers      0         0         0B        0B
Local Volumes   0         0         0B        0B
Build Cache     17        0         2.557GB   49.31kB

Ten gigabytes of images and two and a half of build cache, from one course. This is the normal steady state, and it is why df -h surprises people three months in. Nothing here is cleaned up automatically.

Targeted removal first, since it is the safe kind:

$ docker rm -f demo               # one container, running or not
$ docker rmi builder              # one image
$ docker network rm recon         # one network
$ docker image prune              # dangling images only, no tags
$ docker builder prune            # build cache

docker image prune without -a only removes untagged layers left behind by rebuilds, which is the cleanup you can run without thinking about it.

$ docker system prune -a
WARNING! This will remove:
  - all stopped containers
  - all networks not used by at least one container
  - all images without at least one container associated to them
  - all build cache

Are you sure you want to continue? [y/N]

Read that second to last line carefully. With -a it means every image not currently backing a running container, including the ones you spent an hour building and the base images you will re-download at 400MB a go. Fine on a laptop with fast internet, painful on a plane.

If a lab has gone sideways and you want everything gone right now:

docker kill $(docker ps -q) 2>/dev/null
docker rm $(docker ps -aq) 2>/dev/null
docker rmi $(docker images -q) 2>/dev/null

Moving an image to a machine with no internet

You built the attackbox image. Now you need it on a jump host that cannot pull from Docker Hub, or on a laptop going somewhere with no useful connectivity.

docker save writes an image to a tar stream:

$ docker save attackbox | gzip > attackbox.tar.gz
$ ls -lh attackbox.tar.gz
-rw-rw-r-- 1 evan evan 73M Aug 18 02:59 attackbox.tar.gz

73MB, from a 282MB image. Copy it over by whatever means you have and load it on the far side:

$ docker load -i attackbox.tar.gz
Loaded image: attackbox:latest

Straight down a pipe works too, and skips writing the file at all:

docker save attackbox | gzip | ssh jump 'gunzip | docker load'

save and load work on images. The pair you want for a container’s filesystem is docker export and docker import, which flattens it and drops the history and metadata. Reach for those when you want a container’s contents, not a rebuildable image.

Or push it somewhere

With connectivity, a registry is less work. Tag the image with the registry path and push:

docker tag attackbox ghcr.io/yourname/attackbox:2026-08
docker push ghcr.io/yourname/attackbox:2026-08

Two things to get right before you do that. Make the repository private unless you have read every layer of that image, because docker history shows what was run and any file you ever COPYd is in there permanently, even if a later layer deleted it. And tag with something meaningful, 2026-08 or a git SHA, not latest, so the image you pull on the engagement is the image you tested.

The same rule applies to a Dockerfile you commit: build arguments and env vars are visible in the image metadata. Secrets go in at run time, not build time.

Keep the lab

The whole thing is a directory with three files in it: compose.yaml, a Dockerfile for your image, and whatever tooling you COPY in. Commit it. Add targets as you meet them, break them, docker compose down, and it costs you nothing until the next time you need a network to practise on.