Pwndoc on Ubuntu 24.04 on Azure User Guide
Overview
This image runs Pwndoc 1.6.0, the open source penetration test reporting platform, on Ubuntu 24.04 LTS. Pwndoc replaces the copy-and-paste report workflow with a shared workspace: a reusable vulnerability knowledge base holds your standard findings with their descriptions, remediation advice and CVSS scoring, and each engagement draws from that base instead of starting from a blank document. Testers write findings collaboratively with live updates between everyone in the same audit, attach evidence images, record scope and network hosts, and export the finished engagement to a Word document generated from your own DOCX template.
Upstream Pwndoc ships only as a Docker Compose stack. This image is a native systemd install with no container runtime: the Node.js backend runs from /opt/pwndoc/backend as an unprivileged pwndoc system account, the Vue/Quasar single page app is pre-built and served as static files, and a local MongoDB 8.0 Community database holds every audit, finding, template and evidence image.
nginx terminates TLS on port 443 and is the only listener exposed to the network. It serves the single page app and reverse-proxies /api and /socket.io to the backend on 127.0.0.1:4242, which in turn talks to MongoDB on 127.0.0.1:27017. The backend and the database are bound to loopback and are never directly reachable. Port 80 issues a permanent redirect to HTTPS: Pwndoc sets its session cookies secure and sameSite=strict, so authentication cannot complete over plain HTTP.
What is included:
- Pwndoc 1.6.0 built from a pinned upstream release commit, run by systemd as the unprivileged
pwndocuser - The Pwndoc web UI on
:443over HTTPS, with:80redirecting to it - A per-VM
adminaccount whose password is generated on first boot and recorded in a root-only file - A per-VM 4096-bit TLS certificate, also generated on first boot, replacing the private keys that upstream commits to its public repository
- Per-VM JWT signing secrets and a per-VM MongoDB credential, none of them baked into the image
- MongoDB 8.0 Community, bound to loopback with authentication enabled, its data directory on a dedicated Azure data disk at
/var/lib/pwndoc - A usable starter dataset so you can create an audit immediately: the English language, the default DOCX report template, a
Security Auditaudit type and four vulnerability categories mongod.service,pwndoc.serviceandnginx.serviceas systemd units, enabled and active- An unauthenticated
/healthzendpoint for Azure Load Balancer health probes - 24/7 cloudimg support
Prerequisites
An active Azure subscription, an SSH key pair, and a VNet plus subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a sensible starting point; size up for larger teams and audits with many findings and evidence images. NSG inbound: allow 22/tcp from your management network and 443/tcp for the web interface. Optionally allow 80/tcp so the HTTP-to-HTTPS redirect works for people who type the bare address. Because Pwndoc holds client engagement data, restrict 443/tcp to your own networks or a VPN rather than opening it to the internet.
Step 1 - Deploy from the Azure Marketplace
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for Pwndoc by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTPS (443). Review the dedicated data disk on the Disks tab, then Review + create then Create.
Step 2 - Deploy from the Azure CLI
az vm create \
--resource-group <your-rg> \
--name pwndoc \
--image <marketplace-image-urn> \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values ~/.ssh/id_ed25519.pub \
--vnet-name <your-vnet> --subnet <your-subnet> \
--public-ip-sku Standard
az vm open-port --resource-group <your-rg> --name pwndoc --port 443 --priority 1010
Step 3 - Connect to your VM
ssh azureuser@<vm-public-ip>
Step 4 - Confirm the services are running
nginx on port 443 is the only public listener. It serves the single page app and proxies the API to the backend on 127.0.0.1:4242, which talks to MongoDB on 127.0.0.1:27017. Confirm the services are active and check which sockets are actually exposed:
systemctl is-active mongod pwndoc nginx pwndoc-firstboot
sudo ss -tlnp | grep -E ':80 |:443 |:4242 |:27017 '
Expected output:
active
active
active
active
LISTEN 0 4096 127.0.0.1:27017 0.0.0.0:* users:(("mongod",pid=...))
LISTEN 0 511 127.0.0.1:4242 0.0.0.0:* users:(("node",pid=...))
LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=...))
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=...))
Note that 4242 and 27017 are bound to 127.0.0.1, not 0.0.0.0. The Pwndoc API and the database are reachable only from the VM itself, so nginx is the single point of ingress.

Step 5 - Retrieve your admin password
On the first boot of every VM, pwndoc-firstboot.service generates a unique administrator password, a unique MongoDB credential and unique JWT signing secrets, and writes the credentials into a root-only file. Confirm the file exists and is root-only:
sudo ls -l /root/pwndoc-credentials.txt
Expected output:
-rw------- 1 root root 649 ... /root/pwndoc-credentials.txt
Read the file to get your admin password with sudo cat /root/pwndoc-credentials.txt. It contains PWNDOC_URL, PWNDOC_ADMIN_USER, PWNDOC_ADMIN_PASSWORD and MONGO_PASSWORD, all unique to this VM.
The same first-boot service mints the TLS certificate nginx serves, so the private key on your VM was generated on your VM. This matters here: the upstream Pwndoc repository commits working TLS private keys into its source tree, which means every Docker Compose deployment of Pwndoc shares them. This image deletes those keys during the build and generates a fresh 4096-bit keypair per VM instead.
sudo openssl x509 -in /etc/pwndoc/ssl/server.cert -noout -subject -dates
Expected output (the common name is the address your VM resolved for itself, and the dates are one year apart):
subject=O = cloudimg, CN = 10.0.0.21
notBefore=Jul 19 15:25:02 2026 GMT
notAfter=Jul 19 15:25:02 2027 GMT

Step 6 - Confirm the stack, the health endpoint and that the API is closed
nginx serves an unauthenticated /healthz endpoint for load balancer probes. Everything else in the API requires a session. Confirm the component versions, that the app answers, and that an anonymous caller is refused even when it sends a complete, well-formed request body:
node -v
mongod --version | head -1
curl -sk -o /dev/null -w 'healthz -> %{http_code}\n' https://127.0.0.1/healthz
curl -s -o /dev/null -w 'http redirect -> %{http_code}\n' http://127.0.0.1/
curl -sk -o /dev/null -w 'GET /api/audits -> %{http_code}\n' https://127.0.0.1/api/audits
curl -sk -o /dev/null -w 'POST /api/clients -> %{http_code}\n' -X POST https://127.0.0.1/api/clients \
-H 'Content-Type: application/json' \
-d '{"company":"Acme","lastname":"Doe","firstname":"Jane","email":"jane@example.com"}'
Expected output:
v22.23.1
db version v8.0.26
healthz -> 200
http redirect -> 301
GET /api/audits -> 401
POST /api/clients -> 401
The POST is deliberately sent with a complete body. A request missing required fields could be rejected by input validation before authentication is ever consulted, which would make an open endpoint look protected; sending a valid body proves the 401 came from the authentication layer.

Step 7 - Confirm the administrator bootstrap is closed
Pwndoc creates its first administrator through a one-shot endpoint that stops working the moment any user exists. First boot has already used it, so an anonymous caller can no longer create an account:
curl -sk https://127.0.0.1/api/users/init
curl -sk -o /dev/null -w 'init replay -> %{http_code}\n' -X POST https://127.0.0.1/api/users/init \
-H 'Content-Type: application/json' \
-d '{"username":"someone","password":"Abcdefg1","firstname":"a","lastname":"b"}'
Expected output (datas:false means "no longer uninitialised", and the replay is refused):
{"status":"success","datas":false}
init replay -> 403
Step 8 - Sign in
Browse to https://<vm-public-ip>/. Because the certificate is self-signed and unique to your VM, your browser will warn on the first visit; accept it, or replace the certificate as described under Maintenance. Enter admin and the password from Step 5.

You can confirm the same credential works from the command line. The following block reads the password from the root-only file, checks that a wrong password is rejected, and then checks that the real one is accepted:
PW=$(sudo grep '^PWNDOC_ADMIN_PASSWORD=' /root/pwndoc-credentials.txt | cut -d= -f2-)
curl -sk -o /dev/null -w 'wrong password -> %{http_code}\n' -X POST https://127.0.0.1/api/users/token \
-H 'Content-Type: application/json' -d '{"username":"admin","password":"Definitely-Wrong-Pw1"}'
curl -sk -X POST https://127.0.0.1/api/users/token -H 'Content-Type: application/json' \
-d "$(printf '{"username":"admin","password":"%s"}' "$PW")" | grep -o '"token"' | head -1
Expected output:
wrong password -> 401
"token"
Step 9 - Create your first audit
After signing in you land on Audits, which is empty on a new VM. Click New Audit, give the engagement a name and pick an assessment type. The image ships with a Security Audit type already bound to the default report template and with English configured as a language, so the dialog is usable immediately rather than presenting empty dropdowns.

Inside an audit you fill in the General information and scope, record hosts and services under Network, and add findings. Each finding carries a description, observation, remediation, CVSS vector and category. Findings can be written from scratch or pulled from the shared vulnerability knowledge base under Vulnerabilities, so recurring issues keep consistent wording and scoring across every engagement. Several people can edit the same audit at once, with changes appearing live.

Step 10 - Report templates and Word export
Reports are generated from DOCX templates, so the finished document comes out in your own house style. Open Data then Templates. The image ships with the upstream Default Template already loaded, which is what makes the Security Audit type able to export a report out of the box.

To use your own report design, click Create Template and upload your DOCX, then bind it to an audit type under Data then Custom Data. Upstream documents the available template variables. To export, open an audit and choose the download action from the audit list.
Step 11 - Confirm engagement data lives on the dedicated disk
Every audit, finding, evidence image and uploaded report template lives under /var/lib/pwndoc, a dedicated Azure data disk captured into the image and re-provisioned on every VM:
df -h /var/lib/pwndoc | tail -1
findmnt -no SOURCE,TARGET,FSTYPE /var/lib/pwndoc
grep pwndoc /etc/fstab
sudo ls /var/lib/pwndoc/report-templates
Expected output (the device name varies between VM sizes, which is exactly why the mount is keyed by UUID rather than by device path):
/dev/sdc 30G 202M 28G 1% /var/lib/pwndoc
/dev/sdc /var/lib/pwndoc ext4
UUID=8c58cbdc-4ef0-4ef0-8027-9fac61b83a85 /var/lib/pwndoc ext4 defaults,nofail 0 2
Default Template.docx

Maintenance
- Back up your engagements: everything Pwndoc stores lives in MongoDB's data files under
/var/lib/pwndoc. Snapshot the data disk, or usemongodump/mongorestorewith the credential in/root/pwndoc-credentials.txtfor logical backups. Pwndoc also has its own Data then Import / Export page for portable dumps. - Add teammates: sign in as admin and open Data then Collaborators. There is no self registration, so accounts exist only when an administrator creates them. Roles are managed under Data then Roles.
- Replace the TLS certificate: the per-VM certificate is self-signed, which is why browsers warn on first visit. For production, install your own certificate and key at
/etc/pwndoc/ssl/server.certand/etc/pwndoc/ssl/server.key(keep the key0640 root:pwndoc), then runsudo systemctl restart nginx pwndoc. Both nginx and the backend read that same pair. - Restrict access: Pwndoc holds client findings. Keep
443/tcplimited to your own networks or a VPN in the NSG rather than exposing it to the internet, and prefer an Azure Application Gateway or your own reverse proxy with a CA-issued certificate if it must be public. - Rotate credentials: change the admin password from the profile menu in the UI. The MongoDB credential lives in
/etc/pwndoc/pwndoc.envand the JWT signing secrets in/opt/pwndoc/backend/src/config/config.json; update those and runsudo systemctl restart pwndocto apply. Rotating the JWT secrets invalidates all existing sessions. - Security updates: unattended security upgrades are enabled, so the OS keeps itself patched. Reboot when a new kernel is installed.
Support
cloudimg provides 24/7/365 expert technical support for this image. Contact support@cloudimg.co.uk. Pwndoc is licensed under the MIT License. This image is provided by cloudimg; additional charges apply for build, maintenance and 24/7 support.