Building a Dedicated Web App & API Pentesting Lab

Building a Dedicated Web App & API Pentesting Lab
Zach Tackett
Author: Zach Tackett
Share:

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.

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.2 

zach@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-shop
container_name: juice-shop
restart: unless-stopped
ports:
- "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_data zach@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-manager zach@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.