Over the last several months, I have spent quite a few evenings after getting the kids to bed; working on my home network and building out different penetration testing lab environments. Most of that time has been focused on Active Directory, particularly the Game of Active Directory (GOAD) lab that I have used throughout my AD CS blog series, Paths to Power in Active Directory.
While I have referenced that environment frequently and used it to demonstrate various AD CS misconfigurations and exploitation techniques, I have never taken the time to write a dedicated post about building the lab itself. If there is enough interest, I may go back and do that at some point because there were quite a few lessons learned along the way.
After spending so much time focused on Active Directory, I started thinking about where I wanted to focus next. Web applications and APIs seemed like the natural progression, particularly given my background in web development. A large part of my career was spent building and maintaining web applications and later helping to secure them. That experience has given me a solid understanding of how these systems work and ultimately played a large role in pushing me toward penetration testing.
Rather than relying entirely on public labs and training platforms, I wanted a dedicated environment where I could deploy vulnerable applications, experiment with tooling, research vulnerabilities, and develop repeatable testing workflows. More importantly, I wanted a place where I could break things, rebuild them, and learn from the process.
Table of Contents
Building the Network
Because I was planning to deploy a number of intentionally vulnerable applications, I wanted to keep them separate from the rest of my environment. I already had a dedicated testing VLAN that I had previously built for GOAD and other offensive security projects, so it made sense to host the Docker environment there as well.
After recently redeploying and expanding my GOAD environment, I took the opportunity to redesign the lab network. Rather than relying on my original setup, I created a dedicated testing VLAN with its own subnet, firewall policies, and SSID. This gave me an isolated environment where I could safely deploy vulnerable systems, test different attack paths, and experiment with new tooling without impacting the rest of my network.
Since I already had GOAD running on an Ubuntu host, I decided to build the web application lab there as well rather than introducing another system. The host already had the resources available, and keeping everything in a single environment made it easy to move between Active Directory, web application, and API testing while continuing to use the infrastructure that was already in place.
Choosing the Applications
Before I started creating containers and deploying the applications, I spent some time deciding what I actually wanted the lab to accomplish. I began by researching which vulnerable applications would be best and would allow me to learn a mixture of basic, intermediate, and advanced web app and API exploitation.
The goal wasn't to collect every vulnerable application I could find. I was shooting for a mix of traditional web applications, REST APIs, GraphQL applications, and more realistic application stacks that would expose me to a variety of technologies, frameworks, and attack surfaces. I also wanted the environment to support the research and hands-on practice that would help prepare me for future web application penetration testing certifications.
The final list I decided on looked like this:
| Application | Why I Chose It |
|---|---|
| OWASP Juice Shop | One of the most complete intentionally vulnerable web applications available. |
| DVWA | Useful for practicing common web vulnerabilities and quickly demonstrating concepts. |
| WebGoat | Structured lessons and guided exercises covering many common attack classes. |
| VAmPI | Purpose-built vulnerable REST API. |
| DVGA | GraphQL security testing. |
| XVWA | Additional vulnerable web application scenarios. |
| Mutillidae | Large collection of vulnerable modules covering many attack categories. |
| NodeGoat | Vulnerable Node.js application that mirrors modern JavaScript environments. |
| crAPI | Realistic API testing environment with multiple services and business workflows. |
Together, these applications provide exposure to traditional web application testing, API testing, GraphQL security, modern frameworks, authentication and authorization flaws, business logic issues, and many of the vulnerability classes commonly encountered during real-world assessments.
Getting Docker Ready
Since this Ubuntu system was already hosting GOAD, Docker had already been installed previously. However, if you're starting from a clean Ubuntu installation, install Docker and Docker Compose first using Docker's official installation instructions before continuing.
The first thing I wanted to do was to check and confirm that I had both Docker and Docker Compose installed.
zach@pentest-lab:~$ docker --version Docker version 29.1.3, build 29.1.3-0ubuntu3~24.04.2zach@pentest-lab:~$ docker compose version Docker Compose version v2.40.3
Both Docker and Docker Compose were already installed, so I was able to move directly into building the environment.
Organizing the Environment
With Docker and Docker Compose installed, I wanted a simple way to keep everything organized as the environment grew. Rather than storing compose files throughout the filesystem, I created a dedicated directory under my home folder and gave each application its own directory. This made it easier to manage deployments individually and kept related files grouped together.
Each folder contains the application's docker-compose.yml along with any supporting configuration files or resources it needs. It made the environment much easier to work with, and if I needed to rebuild an application, make a configuration change, or troubleshoot an issue, I know exactly where to find everything without digging through unrelated files.
The directory structure eventually looked like this:
/home/zach/docker
├── juice-shop
├── dvwa
├── webgoat
├── vampi
├── dvga
├── xvwa
├── mutillidae
├── nodegoat
├── crapi
└── nginx-proxy-manager
Deploying the Applications
With the directory structure in place, it was finally time to start deploying the applications individually. Most of them could be brought online with a simple docker-compose.yml, although a few required some additional work along the way.
OWASP Juice Shop
zach@pentest-lab:~$ docker --version Docker version 29.1.3, build 29.1.3-0ubuntu3~24.04.2
zach@pentest-lab:~$ cd ~/docker/juice-shop
zach@pentest-lab:~/docker/juice-shop$ cat > docker-compose.yml <<'EOF' services:juice-shop:image: bkimminich/juice-shopcontainer_name: juice-shoprestart: unless-stoppedports:- "3000:3000"
EOF
zach@pentest-lab:~/docker/juice-shop$ docker compose up -d
What I have done here is I have navigated to the Juice Shop folder and created a docker-compose.yml file containing the configuration needed to deploy the application. This compose file defines the Docker image to use, assigns the container a name, configures it to automatically restart if it stops, and maps port 3000 on the host to port 3000 inside the container.
Once the compose file was in place, running docker compose up -d pulled the image and started the application in the background.
DVWA
zach@pentest-lab:~$ cd ~/docker/dvwa
zach@pentest-lab:~/docker/dvwa$ cat > docker-compose.yml <<'EOF'
services:
dvwa:
image: vulnerables/web-dvwa
container_name: dvwa
restart: unless-stopped
ports:
- "4280:80"
EOF
zach@pentest-lab:~/docker/dvwa$ docker compose up -d
The deployment process for DVWA was nearly identical. I created a new docker-compose.yml file using the appropriate image and port mapping, then started the container with docker compose up -d. Within a few moments, DVWA was running and accessible on port 4280.
WebGoat
zach@pentest-lab:~$ mkdir -p ~/docker/webgoat
zach@pentest-lab:~$ cd ~/docker/webgoat
zach@pentest-lab:~/docker/webgoat$ cat > docker-compose.yml <<'EOF'
services:
webgoat:
image: webgoat/webgoat
container_name: webgoat
restart: unless-stopped
ports:
- "8080:8080"
EOF
zach@pentest-lab:~/docker/webgoat$ docker compose up -d
During my build, Docker reported the WebGoat container as unhealthy. Despite that status, the application functioned normally throughout my testing. I couldn't find any documentation indicating this is a known issue with the official image, so I recommend verifying that the application is accessible in your browser and reviewing the container logs before assuming the deployment failed.
In this case, simply navigating to http://webgoat.pentest.lab (or http://<server-ip>:8080/WebGoat if you're not using Nginx Proxy Manager) confirmed that WebGoat was fully operational despite Docker reporting the container as unhealthy.
VAmPI
zach@pentest-lab:~$ mkdir -p ~/docker/vampi
zach@pentest-lab:~$ cd ~/docker/vampi
zach@pentest-lab:~/docker/vampi$ cat > docker-compose.yml <<'EOF'
services:
vampi:
image: erev0s/vampi
container_name: vampi
restart: unless-stopped
ports:
- "5000:5000"
EOF
zach@pentest-lab:~/docker/vampi$ docker compose up -d
VAmPI followed the same deployment process. After creating a directory for the application, I created a docker-compose.yml file using the VAmPI image, exposed port 5000, and started the container. Within a few moments, the intentionally vulnerable API was ready for testing.
DVGA
zach@pentest-lab:~$ cd ~/docker/dvga
zach@pentest-lab:~/docker/dvga$ cat > docker-compose.yml <<'EOF'
services:
dvga:
image: dolevf/dvga
container_name: dvga
restart: unless-stopped
ports:
- "5013:5013"
EOF
zach@pentest-lab:~/docker/dvga$ docker compose up -d
Like the previous applications, DVGA was deployed using a docker-compose.yml file and started with the docker compose up -d command.
XVWA
zach@pentest-lab:~$ cd ~/docker/xvwa
zach@pentest-lab:~/docker/xvwa$ cat > Dockerfile <<'EOF'
FROM php:7.4-apache
RUN docker-php-ext-install mysqli
RUN apt-get update && apt-get install -y git unzip \
&& rm -rf /var/lib/apt/lists/*
RUN rm -rf /var/www/html/* \
&& git clone https://github.com/s4n7h0/xvwa.git /var/www/html
RUN chown -R www-data:www-data /var/www/html
EOF
zach@pentest-lab:~/docker/xvwa$ cat > docker-compose.yml <<'EOF'
services:
xvwa:
build: .
container_name: xvwa
restart: unless-stopped
ports:
- "8081:80"
EOF
zach@pentest-lab:~/docker/xvwa$ docker compose up -d --build
Unlike the earlier applications, XVWA was built locally from a Dockerfile instead of pulling a pre-built image from Docker Hub. The image I originally planned to use relied on an outdated image manifest that is no longer supported by current Docker releases, causing the deployment to fail.
Building the image locally allowed me to clone the latest XVWA source code directly from GitHub and create a compatible image that worked correctly with the current version of Docker.
Mutillidae
zach@pentest-lab:~$ cd ~/docker/mutillidae
zach@pentest-lab:~/docker/mutillidae$ cat > docker-compose.yml <<'EOF'
services:
database:
image: webpwnized/mutillidae:database
container_name: mutillidae-database
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: mutillidae
MYSQL_DATABASE: mutillidae
directory:
image: webpwnized/mutillidae:ldap
container_name: mutillidae-directory
restart: unless-stopped
mutillidae:
image: webpwnized/mutillidae:www
container_name: mutillidae
restart: unless-stopped
depends_on:
- database
- directory
ports:
- "8082:80"
EOF
zach@pentest-lab:~/docker/mutillidae$ docker compose up -d
Mutillidae requires a slightly different deployment than the other applications in the lab. Rather than running as a single container, it relies on separate MySQL and LDAP containers to provide the backend services it depends on. Fortunately, Docker Compose takes care of deploying and networking all of the required services together, so bringing the full application online is still as simple as running docker compose up -d.
NodeGoat
zach@pentest-lab:~$ cd ~/docker
zach@pentest-lab:~/docker$ git clone https://github.com/OWASP/NodeGoat.git nodegoat
zach@pentest-lab:~/docker$ cd nodegoat
zach@pentest-lab:~/docker/nodegoat$ docker compose up -d
NodeGoat was one of the easier applications to deploy since the project already includes a Docker Compose configuration. After cloning the repository, all I had to do was start the containers. The first time you browse to the application, you'll be prompted to create an administrator account before you can begin working through the lessons.
crAPI
zach@pentest-lab:~/docker$ cd ~/docker
zach@pentest-lab:~/docker$ curl -L -o /tmp/crapi.zip https://github.com/OWASP/crAPI/archive/refs/heads/main.zip
zach@pentest-lab:~/docker$ unzip /tmp/crapi.zip
zach@pentest-lab:~/docker$ mv crAPI-main crapi
zach@pentest-lab:~/docker$ cd crapi/deploy/docker
zach@pentest-lab:~/docker/crapi/deploy/docker$ docker compose pull
zach@pentest-lab:~/docker/crapi/deploy/docker$ LISTEN_IP="0.0.0.0" docker compose -f docker-compose.yml --compatibility up -d
crAPI was the largest application I added to the lab and required a little more work than the others. Rather than pulling a single image from Docker Hub, the project is distributed as a complete Docker Compose environment with multiple supporting services. After downloading the project from GitHub and extracting it, I navigated to the included Docker deployment directory, pulled the required images, and started the environment. Setting LISTEN_IP to 0.0.0.0 allows the application to listen on all network interfaces, making it accessible from other systems on the lab network.
Portainer
Before I started deploying vulnerable applications, I wanted a simple way to monitor everything that would eventually be running on the system.
While I still prefer managing containers from the command line, I knew the environment would eventually include multiple applications, databases, and supporting services. Having a centralized dashboard to view container health, logs, resource utilization, and restart services when needed would make day-to-day management much easier.
After looking at a few different container management solutions, I decided on Portainer. It provided everything I was looking for in a lightweight package and only required a single container to get up and running.
The first step was creating a persistent Docker volume to store Portainer's configuration and data.
zach@pentest-lab:~$ docker volume create portainer_datazach@pentest-lab:~$ docker run -d \ --name portainer \ --restart unless-stopped \ -p 9443:9443 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v portainer_data:/data \ portainer/portainer-ce:latest
Once the container finished starting, Portainer was available by browsing to:
https://<server-ip>:9443
During the initial setup, I created an administrator account and connected Portainer to the local Docker environment. Now Portainer will provide me with a simple way to monitor running containers, inspect logs, review resource utilization, and quickly restart services whenever something isn't behaving as expected.
Although I still perform most deployments and maintenance from the command line, Portainer has become a useful tool for getting a quick overview of the entire environment without needing to run multiple Docker commands.
Verifying the Environment
With the applications deployed, the next step was making sure everything was running as expected.
Running docker ps quickly confirmed that the containers were up, but it also highlighted something I had anticipated from the beginning. While some applications consisted of a single container, others relied on several supporting services.
For example, crAPI includes PostgreSQL, MongoDB, identity services, MailHog, API gateways, and several additional application components. Likewise, Mutillidae depends on separate MySQL and LDAP containers.
Since I was primarily interested in verifying the applications themselves, I created a simple command that displays only the primary lab containers instead of doing a simple docker ps that would have been an overly large amount of output to parse through.
zach@pentest-lab:~$ for c in juice-shop dvwa vampi webgoat dvga xvwa mutillidae nodegoat-web-1 crapi-web; do docker ps --filter "name=$c" --format "table \t\t"; done
NAMES STATUS PORTS
juice-shop Up 9 days 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp
NAMES STATUS PORTS
dvwa Up 9 days 0.0.0.0:4280->80/tcp, [::]:4280->80/tcp
NAMES STATUS PORTS
vampi Up 9 days 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp
NAMES STATUS PORTS
webgoat Up 9 days (unhealthy) 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp, 9090/tcp
NAMES STATUS PORTS
dvga Up 9 days 0.0.0.0:5013->5013/tcp, [::]:5013->5013/tcp
NAMES STATUS PORTS
xvwa Up 9 days 0.0.0.0:8081->80/tcp, [::]:8081->80/tcp
NAMES STATUS PORTS
mutillidae Up 9 days 443/tcp, 0.0.0.0:8082->80/tcp, [::]:8082->80/tcp
mutillidae-database Up 9 days 3306/tcp
mutillidae-directory Up 9 days 389/tcp, 636/tcp
NAMES STATUS PORTS
nodegoat-web-1 Up 9 days 0.0.0.0:4000->4000/tcp, [::]:4000->4000/tcp
NAMES STATUS PORTS
crapi-web Up 9 days (healthy) 0.0.0.0:8888->80/tcp, 0.0.0.0:30080->80/tcp, 0.0.0.0:8443->443/tcp, 0.0.0.0:30443->443/tcp
That made it much easier to quickly verify the status of the environment without scrolling through every supporting service.
Adding Nginx Proxy Manager
With the applications deployed and running, I quickly realized that remembering ports was becoming a pain.
At first, every application was accessed directly using the host's IP address and an individual port.
Juice Shop -> :3000
DVWA -> :4280
WebGoat -> :8080
VAmPI -> :5000
DVGA -> :5013
XVWA -> :8081
Mutillidae -> :8082
NodeGoat -> :4000
crAPI -> :8888
It worked, but it wasn't very convenient. I found myself constantly referring back to my notes to remember which application lived on which port.
I eventually settled on Nginx Proxy Manager. I could have accomplished the same thing with a standard Nginx configuration, and for a lab this size that honestly would have been perfectly reasonable. Since I plan on continuing to expand this environment, though, I liked the idea of having a web interface to manage proxy hosts instead of manually editing Nginx configuration files every time I added a new application.
zach@pentest-lab:~$ mkdir -p ~/docker/nginx-proxy-manager zach@pentest-lab:~$ cd ~/docker/nginx-proxy-managerzach@pentest-lab:~/docker/nginx-proxy-manager$ cat > docker-compose.yml <<'EOF' services: nginx-proxy-manager: image: jc21/nginx-proxy-manager:latest container_name: nginx-proxy-manager restart: unless-stopped ports: - "80:80" - "81:81" - "443:443" volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt EOF zach@pentest-lab:~/docker/nginx-proxy-manager$ docker compose up -d
Once the container finished starting, the administrative interface was available at:
http://192.168.50.151:81
You may notice that the administrative interface uses port 81 instead of the standard HTTP port80. This is intentional and is the default configuration for Nginx Proxy Manager. Since thereverse proxy itself needs to listen on ports 80 (HTTP) and 443 (HTTPS) to handle traffic for thehosted applications, the management interface is exposed separately on port 81.
After logging in, I created a reverse proxy host for each application, pointing the hostname to the appropriate Docker container and port. Each proxy host forwards requests to the corresponding container running on the Ubuntu host, allowing the applications to be accessed using friendly hostnames instead of remembering individual port numbers. For example:
| Hostname | Port |
|---|---|
| juiceshop.pentest.lab | 3000 |
| dvwa.pentest.lab | 4280 |
| webgoat.pentest.lab | 8080 |
| vampi.pentest.lab | 5000 |
| dvga.pentest.lab | 5013 |
| xvwa.pentest.lab | 8081 |
| mutillidae.pentest.lab | 8082 |
| nodegoat.pentest.lab | 4000 |
| crapi.pentest.lab | 8888 |
Initially, I ran into an issue where the reverse proxy was working correctly, but my Mac couldn't resolve any of the hostnames. The applications were still accessible using their IP addresses and ports, which helped narrow the issue down to DNS rather than Nginx Proxy Manager itself.
As a temporary solution, I added entries to my local hosts file so the hostnames would resolve correctly while I continued building the environment. Eventually, I plan to move those records into my local DNS infrastructure so every device on the lab network can resolve them automatically. Since this was only needed for systems outside the Ubuntu host, the reverse proxy itself was functioning correctly the entire time.
The Final Environment
At this point, the environment had grown into exactly what I was hoping it would become. Everything runs from a single Ubuntu host alongside my GOAD environment, with the applications isolated on their own VLAN and accessible through friendly hostnames using Nginx Proxy Manager.
Lessons Learned
Overall, the build went much smoother than I expected. However, there were still a few things that caught me off guard.
Although Docker was already present on the Ubuntu system, Docker Compose wasn't. Since every application in this lab relies on Compose, that was one of the first things I had to address before moving any further.
XVWA was the biggest example of this. The public image I initially attempted to use was built using an outdated manifest that newer versions of Docker no longer support. Building the application locally ended up being a much better long-term solution.
Applications like Juice Shop and DVWA are single-container deployments, but others, such as Mutillidae and especially crAPI, rely on several supporting services. Seeing dozens of containers after running docker ps is completely normal.
Keeping every application in its own directory with its own Compose file makes updates, troubleshooting, and rebuilding individual services much easier once the environment starts growing.
Accessing everything by IP address and port worked, but it quickly became frustrating. Setting up Nginx Proxy Manager made navigating the environment significantly easier and made the lab feel much closer to a real deployment.
That was really the whole reason for choosing Docker in the first place. Containers are disposable. If I completely break an application while testing, I can simply remove the container, redeploy it, and be back where I started in a matter of minutes.
Coming Back Full Circle
One thing this project reminded me of while writing this post is how much work has already gone into making intentionally vulnerable environments easier to deploy. During graduate school, I spent quite a bit of time using OWASP's Samurai Web Training Framework (WTF) in several classes while learning penetration testing, so it is pretty awesome to later become part of a team where several of the project's maintainers are my coworkers.
Along with OWASP Katana, both projects make it much easier to deploy and manage intentionally vulnerable environments for hands-on practice. While I wanted to build something tailored specifically to my own workflow and learning objectives, they're both excellent resources if you're looking to get started without building everything from scratch. I'm also looking forward to contributing additional labs and targets to Katana to help the project continue to grow.
Wrapping Up
Building the lab was really just the first step. The real goal is to have an environment where I can research vulnerabilities, experiment with new tools, and develop repeatable testing workflows while continuing to improve my web application and API testing skills. Having everything running locally also gives me the flexibility to intentionally break applications, rebuild them, and try new attack techniques without constantly spinning up new cloud environments.
But overall, I'm also planning to use this environment as the foundation for future blog posts focused on web application and API security. Rather than just abstractly writing about vulnerabilities or the theory about how they're exploited, I'll be able to demonstrate them against applications that anyone can deploy themselves.
Just as GOAD became the backbone of my Active Directory research, I'm hoping this environment becomes the same for everything related to web application and API security.