Containerize a Python Scanner with Docker

Package a WCAG accessibility scanner into a Docker image with Postgres.

Introduction

30 Second Summary

Every website should be usable by everyone, including people who rely on screen readers, keyboard navigation, or other assistive tools. But checking whether a site meets accessibility standards usually means clicking through audit tools one page at a time.

In this project, you will build a command-line scanner that checks any URL for accessibility violations, then package it inside Docker so it runs identically on any machine without manual setup.

What You'll Build

You will run a single command, pass it a URL, and watch your scanner open a headless browser, execute the axe-core accessibility engine, and print every WCAG violation as structured JSON you can pipe into any tool or database.

By the end of this project, you'll have:

  • A working CLI scanner you can point at any website to instantly see which WCAG rules it violates, with severity levels and the affected HTML elements.
  • A portable Docker image that runs the scanner on any machine with zero local dependencies. No Python install, no browser download, no library conflicts.
  • A docker-compose stack running the scanner alongside Postgres, so every scan is persisted and you can query historical results to track accessibility improvements over time.
  • Secret Mission: Shrink your Docker image from over 2GB down to under 1.8GB using a multi-stage build.

Are there any prerequisites?

You should be comfortable with Python basics like writing functions, running CLI scripts, and using pip. No Docker experience is required. This project teaches Docker from scratch.

Before We Start

Before writing any scanner code, you need a workspace with the right tools ready to go. Your AccessAgent scanner depends on Python packages, a headless browser, and Docker. Getting these in place now means zero friction when you start building.

In this step, you will create a project folder, install your Python dependencies, download Chromium for headless browsing, and confirm Docker is ready to containerize everything later.

In this step, get ready to:
  • Create the project folder and open it in VS Code.
  • Set up a virtual environment and install Python dependencies.
  • Install Chromium for Playwright and verify Docker is running.
Create the project folder

Every project starts with a dedicated folder. You will create one on your Desktop so it is easy to find.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir accessagent-scanner
cd accessagent-scanner
  • Confirm the folder exists by running:
pwd

You should see a path ending in accessagent-scanner.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop and select the accessagent-scanner folder, then click Open.

Why open VS Code now?

You will write all your scanner code in VS Code starting in the next step. Opening it on the project folder now means the integrated terminal and file explorer are already pointed at the right place.

Set up the virtual environment and install dependencies

A virtual environment keeps your project's packages isolated from your system Python. This prevents version conflicts between different projects on your machine.

  • Back in your terminal, create a virtual environment by running:
python3.13 -m venv .venv
  • Activate the virtual environment:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\activate

Your terminal prompt now shows (.venv) at the beginning. This confirms you are working inside the virtual environment.

  • Install the four Python packages your scanner needs by running:
pip install playwright==1.61.0 axe-playwright-python==0.1.7 pydantic==2.13.4 psycopg2-binary==2.9.12

You will see download progress bars and a final line reading Successfully installed followed by the package names.

What does each package do?

  • playwright controls a headless Chromium browser to load web pages for scanning.
  • axe-playwright-python runs the axe-core accessibility engine against a loaded page.
  • pydantic structures the raw scan results into clean, typed Python models.
  • psycopg2-binary connects to PostgreSQL so you can persist scan results later.

Seeing an installation error?

  • Check that your virtual environment is active. You should see (.venv) in your terminal prompt.
  • If pip is not found, try pip3 instead.
  • Make sure you typed the version numbers exactly as shown, including the double equals signs.

Help me troubleshoot my pip install error in the accessagent-scanner project.

Install Chromium and verify Docker

Playwright needs an actual browser binary to open web pages. The playwright install command downloads Chromium into a local cache so your scanner can launch it headlessly.

  • Download Chromium for Playwright by running:
playwright install chromium

You will see a download progress bar as Chromium is fetched and stored locally.

  • Verify that Playwright can import correctly by running:
python -c "from playwright.sync_api import sync_playwright; print('OK')"

Before you run this, what do you expect to see if everything installed correctly?

You should see OK printed on a single line. This confirms Playwright and its browser are ready.

Not seeing OK?

  • If you see ModuleNotFoundError, your virtual environment may not be active. Re-run the activate command from the previous substep.
  • If you see an error about missing browsers, re-run playwright install chromium and wait for it to finish.

Help me fix my Playwright installation issue.

Now confirm that Docker Desktop is running. You will use Docker later to containerize your scanner.

  • Check your Docker version by running:
docker --version
  • Confirm the Docker daemon is active by running:
docker info

You should see version information and a block of system details (server version, storage driver, number of containers). If you see an error about the daemon not running, open Docker Desktop from your applications and wait for it to start, then try again.

Cannot connect to the Docker daemon?

  • Open Docker Desktop and wait until the whale icon in your menu bar stops animating. This means the daemon is ready.
  • Run docker info again after the icon settles.

Help me get Docker Desktop running so docker info works.

Your workspace is ready. You have a project folder, an active virtual environment with all dependencies, Chromium installed for headless browsing, and Docker standing by. Next up, you will write the scanner itself and watch it detect real accessibility violations.

Write the Scanner CLI

Your environment is locked and loaded. Python, Playwright, and Chromium are all installed and ready.

Before we learn Docker, we need something worth containerizing. In this step, you will build the actual scanner that opens a URL in a headless browser, runs the axe-core accessibility engine against it, and prints every WCAG violation as structured JSON.

In this step, get ready to:
  • Define Pydantic models that structure raw axe-core output into typed Python objects.
  • Write a CLI that launches headless Chromium, scans a URL, and prints JSON results.
  • Run the scanner against a real website and see accessibility violations in your terminal.
Create the data models

The axe-core engine returns a large nested dictionary with violation IDs, severity levels, WCAG tags, and affected HTML elements. Raw dictionaries are hard to work with. Pydantic models give you typed, validated Python objects instead.

  • Create a new file called models.py in your accessagent-scanner folder and paste the following code:
from datetime import datetime, timezone
from pydantic import BaseModel


# Represents a single HTML element that triggered a violation
class ViolationNode(BaseModel):
    html: str
    target: list[str]


# Represents one accessibility rule violation
class Violation(BaseModel):
    id: str
    impact: str
    description: str
    help_url: str
    wcag_tags: list[str]
    nodes: list[ViolationNode]


# Top-level scan result containing all violations for a URL
class ScanResult(BaseModel):
    url: str
    scanned_at: datetime
    violations_count: int
    violations: list[Violation]

What does this code do?

These three classes mirror the shape of axe-core's output. ViolationNode captures the HTML snippet and CSS selector of an affected element. Violation groups the rule ID, severity, WCAG tags, and all nodes that broke that rule.

ScanResult wraps everything into a single object with metadata (URL, timestamp, total count). Pydantic validates the data at construction time, so malformed output from axe-core will raise an error immediately.

  • Below the ScanResult class (still inside it, indented), add this classmethod that transforms raw axe-core output into your typed models:
    @classmethod
    def from_axe_response(cls, url: str, response: dict) -> "ScanResult":
        # Parse each violation from the raw axe-core response dict
        violations = []
        for v in response.get("violations", []):
            # Keep only WCAG-specific tags (e.g. wcag2a, wcag21aa)
            wcag_tags = [t for t in v.get("tags", []) if t.startswith("wcag")]
            nodes = [
                ViolationNode(
                    html=n.get("html", ""),
                    target=n.get("target", []),
                )
                for n in v.get("nodes", [])
            ]
            violations.append(
                Violation(
                    id=v["id"],
                    impact=v.get("impact", "unknown"),
                    description=v.get("description", ""),
                    help_url=v.get("helpUrl", ""),
                    wcag_tags=wcag_tags,
                    nodes=nodes,
                )
            )
        return cls(
            url=url,
            scanned_at=datetime.now(timezone.utc),
            violations_count=len(violations),
            violations=violations,
        )

What does from_axe_response do?

This classmethod is a factory that takes the raw dictionary from axe-core and constructs a clean ScanResult. It loops through each violation, filters tags to keep only WCAG-relevant ones, and maps affected nodes into ViolationNode objects.

The timestamp is captured at scan time using UTC, so you always know exactly when a scan ran regardless of time zone.

  • Save models.py.
  • Verify the models import correctly by running this command in your terminal:
python -c "from models import ScanResult; print('OK')"

You should see OK printed. This confirms your models file has no syntax errors and Pydantic is working.

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.
  • Confirm you are running the command from inside the accessagent-scanner folder, not a parent directory.
  • Check that models.py is saved (not still unsaved in VS Code).

Help me debug this ModuleNotFoundError for models.py

✔️ Awesome, I've got everything!

Great. Double check you have saved models.py and move on to writing the scanner.

ⓧ I'd like to double check the full code

Scroll up and confirm your models.py contains these sections in order:

  • Imports (datetime, timezone, BaseModel).
  • ViolationNode class with html and target fields.
  • Violation class with id, impact, description, help_url, wcag_tags, nodes fields.
  • ScanResult class with url, scanned_at, violations_count, violations fields plus the from_axe_response classmethod.
Write the scanner function

With your data models defined, you can now write the code that actually opens a browser, navigates to a URL, and fires the axe-core engine. The axe-playwright-python library handles injecting axe-core into the page.

  • Create a new file called scanner.py in your accessagent-scanner folder and paste the following code:
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

from axe_playwright_python.sync_playwright import Axe
from playwright.sync_api import sync_playwright

from models import ScanResult


def scan_url(url: str) -> ScanResult:
    # Initialize axe-core and launch headless Chromium
    axe = Axe()
    with sync_playwright() as pw:
        browser = pw.chromium.launch(headless=True)
        page = browser.new_page()
        # Wait for the page to fully load before scanning
        page.goto(url, wait_until="networkidle")
        results = axe.run(page)
        browser.close()

    # Convert raw axe-core dict into typed Pydantic model
    return ScanResult.from_axe_response(url, results.response)

What does scan_url do?

This function is the heart of your scanner. It creates an Axe instance, launches a headless Chromium browser via Playwright, navigates to the target URL, and waits for the network to go idle (so dynamic content loads).

Then axe.run(page) injects the axe-core JavaScript engine into the page and evaluates every WCAG rule. The raw response dictionary gets passed to your from_axe_response classmethod, which returns a clean ScanResult object.

  • Below the scan_url function, add the CLI entry point that accepts a URL argument and prints the results:
def main():
    # Set up CLI argument parsing
    parser = argparse.ArgumentParser(description="AccessAgent WCAG Scanner")
    parser.add_argument("url", help="URL to scan for accessibility violations")
    args = parser.parse_args()

    # Run the scan and serialize results to JSON
    result = scan_url(args.url)
    result_json = result.model_dump_json(indent=2)

    # Print structured JSON to stdout
    print(result_json)


if __name__ == "__main__":
    main()

What does the main function do?

The main function uses Python's built-in argparse to accept a URL from the command line. It calls scan_url, converts the Pydantic model to indented JSON using model_dump_json, and prints it to stdout.

The if __name__ == "__main__" guard ensures main() only runs when you execute the file directly, not when it is imported as a module.

  • Save scanner.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py and move on to running the scanner.

ⓧ I'd like to double check the full code

Scroll up and confirm your scanner.py contains these sections in order:

  • Imports: argparse, json, os, sys, datetime, timezone, Path, Axe, sync_playwright, ScanResult.
  • scan_url function that launches Chromium, runs axe, and returns a ScanResult.
  • main function that parses the CLI argument and prints JSON.
  • if __name__ == "__main__" guard calling main().
Run the scanner and verify output

Your scanner is complete. Time to point it at a real website and see what comes back.

  • Before you run the command below, predict: do you think https://example.com will have accessibility violations, or will it pass cleanly?
  • Run the scanner by executing this command:
python scanner.py https://example.com

You will see structured JSON printed to your terminal. It includes the URL, a timestamp, a violation count, and a list of any violations found (each with an ID, impact level, WCAG tags, and affected HTML nodes).

What if I see zero violations?

Some simple pages (like example.com) may report zero violations. That is a valid result. The scanner still ran axe-core and confirmed the page passes all rules. Try scanning a more complex site to see real violations.

Seeing a connection or timeout error?

  • Make sure you have an active internet connection. The scanner needs to fetch the target page.
  • Check that the URL includes the full protocol (https://). Passing just example.com without the protocol will fail.
  • If you see a Chromium crash error, confirm you installed the browser with playwright install chromium in the previous step.

Help me debug a scanner error when running python scanner.py

Your scanner is alive. You can now point it at any public URL and get a structured accessibility report in seconds. Next up, you will package this scanner into a Docker container so it runs on any machine without installing Python, Playwright, or Chromium locally.

Containerize with a Dockerfile

Your scanner works locally because you manually installed Python, Playwright, and Chromium. Anyone else wanting to run it would need to replicate that exact environment step by step.

Docker solves this by packaging your code and all its dependencies into a single image. In this step, you'll write a Dockerfile that builds a portable image anyone can run with one command.

In this step, get ready to:
  • Write a Dockerfile that packages the scanner with Python, Playwright, and Chromium.
  • Create a .dockerignore to keep unnecessary files out of the image.
  • Build and run the container to verify it produces the same JSON output as your local scanner.
Create the requirements file

A Dockerfile needs to know which pip packages to install. You'll capture those in a requirements.txt file that pins every dependency to an exact version.

  • Create a file called requirements.txt in your accessagent-scanner/ folder with these contents:
playwright==1.61.0
axe-playwright-python==0.1.7
pydantic==2.13.4
psycopg2-binary==2.9.12

What does this file do?

  • requirements.txt pins every dependency to an exact version so the Docker image builds identically every time.
  • playwright drives the headless browser. axe-playwright-python runs the accessibility engine on each page.
  • pydantic structures the scan output. psycopg2-binary connects to Postgres (you'll use this in a later step).
  • Save requirements.txt.
Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to build an image layer by layer. Each instruction adds a new layer on top of the previous one.

  • Create a file called Dockerfile (no file extension) in your accessagent-scanner/ folder with this content:
# Start from the official Python 3.13 image with full Debian libraries
FROM python:3.13-bookworm

# Set the working directory inside the container
WORKDIR /app

# Copy requirements first to leverage Docker layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Install Chromium and all its system-level dependencies
RUN playwright install --with-deps chromium

# Copy the rest of your application code
COPY . .

# Set the default command when the container starts
ENTRYPOINT ["python", "scanner.py"]

What does each layer do?

  • FROM python:3.13-bookworm starts from a Debian-based image that already has Python 3.13 installed.
  • WORKDIR /app creates and switches to the /app directory inside the container.
  • COPY requirements.txt . copies just the requirements file first. Docker caches this layer, so it only re-installs packages when dependencies change.
  • RUN pip install --no-cache-dir -r requirements.txt installs your Python packages without keeping the download cache (saves image space).
  • RUN playwright install --with-deps chromium downloads the Chromium binary and all system libraries it needs (fonts, graphics libs).
  • COPY . . copies your application code into the image.
  • ENTRYPOINT ["python", "scanner.py"] makes the container run your scanner when it starts. Any arguments you pass to docker run get appended after this command.
  • Save Dockerfile.
  • Confirm both new files exist by running:
ls

You should see Dockerfile, models.py, requirements.txt, and scanner.py listed in the output.

Don't see both files?

  • Make sure you're inside the accessagent-scanner/ directory when you run ls.
  • The Dockerfile must have no file extension. If your editor added .txt automatically, rename it to just Dockerfile.
  • help me find my Dockerfile
Write a .dockerignore

When Docker builds an image, it sends every file in the current directory to the build engine as context. A .dockerignore file tells Docker which files to skip, keeping the build fast and the image clean.

  • Create a file called .dockerignore in your accessagent-scanner/ folder with this content:
__pycache__/
.venv/
.git/
results/
*.pyc
.env

Why exclude these?

Your virtual environment (.venv/) and compiled bytecode (__pycache__/) are platform-specific. Including them would bloat the image and potentially break things inside the container.

The .env file often holds secrets that should never be baked into an image. The results/ folder will hold scan output later and doesn't belong in the build.

  • Save .dockerignore.
  • Confirm it exists by running:
ls -a

You should see .dockerignore in the listing (dotfiles only show up with the -a flag).

Build and run the container

All three files are in place. Time to build a Docker image from your Dockerfile and run it as a container.

  • Build the image by running this command:
docker build -t accessagent-scanner .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction as a layer.
  • -t accessagent-scanner tags (names) the resulting image so you can refer to it later.
  • . tells Docker to use the current directory as the build context (filtered by your .dockerignore).

This takes a few minutes the first time because Docker downloads the base image and installs Chromium. You'll see each step logged as it runs.

When the build finishes, you should see a success message and no error output.

Build failing?

  • If you see a network error while downloading packages, check your internet connection and try again.
  • If Docker says it can't find the Dockerfile, make sure you're running the command from inside accessagent-scanner/ (the folder that contains the Dockerfile).
  • If you see a permissions error, make sure Docker Desktop is running.
  • help me fix my Docker build error

Now for the real test. Before you run the container, think about what you expect to see. Will the output be different from running locally, or the same?

  • Run the scanner inside a container by running this command:
docker run --init --ipc=host accessagent-scanner https://example.com

You should see the same JSON scan results you got when running locally with python scanner.py. The output is identical because the container has the same Python code, packages, and Chromium browser.

Why --init and --ipc=host?

Chromium spawns child processes that can become zombies when the main process exits. The --init flag adds a tiny init process that reaps these zombie processes automatically.

The --ipc=host flag shares the host's shared memory with the container. Without it, Chromium can crash with out-of-memory errors because Docker's default shared memory allocation (64MB) is too small for a browser.

Container runs but you see an error?

  • If Chromium crashes with a segfault or OOM error, double-check that you included both --init and --ipc=host flags.
  • If you see a ModuleNotFoundError, your requirements.txt might be missing a package. Compare it to the version above and rebuild with docker build -t accessagent-scanner ..
  • help me debug my Docker container error

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile:

FROM python:3.13-bookworm

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

RUN playwright install --with-deps chromium

COPY . .

ENTRYPOINT ["python", "scanner.py"]

Here is requirements.txt:

playwright==1.61.0
axe-playwright-python==0.1.7
pydantic==2.13.4
psycopg2-binary==2.9.12

Here is .dockerignore:

__pycache__/
.venv/
.git/
results/
*.pyc
.env

Your scanner is now containerized. Anyone with Docker installed can pull this image and scan a URL without installing Python, Playwright, or Chromium themselves. Next up, you'll use Docker volumes to save scan results as files on your host machine.

Extract Results with Docker Volumes

Your scanner works inside the container, but it prints JSON to stdout. Once the container exits, that output disappears with it. For a real pipeline, downstream agents need scan results saved as files on your host machine.

Docker volumes solve this by mounting a folder from your host into the container's filesystem. Any file the container writes to the mounted path appears on your host instantly, surviving long after the container stops.

In this step, get ready to:
  • Add an --output-dir flag to scanner.py that writes results to a timestamped JSON file.
  • Rebuild the Docker image and run it with a volume mount.
  • Verify the scan results file appears on your host machine.
Add file output to the scanner

The scanner currently prints results and forgets them. You need it to optionally write those results to a file so they persist after the container exits.

  • In scanner.py, find your existing main() function. It currently looks like this:
def main():
    parser = argparse.ArgumentParser(description="AccessAgent WCAG Scanner")
    parser.add_argument("url", help="URL to scan for accessibility violations")
    args = parser.parse_args()

    result = scan_url(args.url)
    result_json = result.model_dump_json(indent=2)

    print(result_json)
  • Replace the entire main() function (from def main(): down to print(result_json)) with this updated version:
def main():
    parser = argparse.ArgumentParser(description="AccessAgent WCAG Scanner")
    parser.add_argument("url", help="URL to scan for accessibility violations")
    parser.add_argument(
        "--output-dir",
        help="Directory to write JSON results file",
        default=None,
    )
    args = parser.parse_args()

    result = scan_url(args.url)
    result_json = result.model_dump_json(indent=2)

    # Always print to stdout
    print(result_json)

    # Optionally write to a file if output directory is specified
    if args.output_dir:
        output_path = Path(args.output_dir)
        output_path.mkdir(parents=True, exist_ok=True)
        timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
        file_path = output_path / f"scan_{timestamp}.json"
        file_path.write_text(result_json)
        print(f"\nResults saved to: {file_path}", file=sys.stderr)

What does this code do?

  • The new --output-dir argument is optional. When omitted, the scanner behaves exactly as before (stdout only).
  • When the flag is set, Path.mkdir(parents=True, exist_ok=True) creates the output directory if it does not exist yet.
  • The filename uses a UTC timestamp (scan_20260711_143022.json) so each scan produces a unique file.
  • The file=sys.stderr argument keeps the "saved" message out of stdout, so only clean JSON goes to stdout.
  • Save scanner.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py and that the if __name__ == "__main__": block at the bottom is still intact.

ⓧ I'd like to double check the full code

The full file is over 30 lines, so confirm each section is present in this order:

  • Imports at the top: argparse, json, os, sys, datetime, Path, Axe, sync_playwright, ScanResult.
  • The scan_url() function (unchanged from before).
  • The updated main() function with --output-dir argument and file-writing logic.
  • The if __name__ == "__main__": block calling main().
Rebuild the image and run with a volume mount

Your Docker image still contains the old scanner.py without the --output-dir flag. You need to rebuild it so the container picks up your changes.

  • Rebuild the Docker image by running:
docker build -t accessagent-scanner .

You should see the build complete with a line showing the image tagged as accessagent-scanner.

What is a Docker volume?

A Docker volume (specifically a bind mount) maps a directory on your host machine to a path inside the container. The -v flag uses the format host_path:container_path.

When the container writes a file to /app/results, that file actually lands in ./results/ on your host. The container does not need to know it is writing to your machine.

Before you run the next command, predict: will the scanner still print JSON to your terminal, or will it only write to the file?

  • Run the scanner with a volume mount by running:
docker run --init --ipc=host -v $(pwd)/results:/app/results accessagent-scanner https://example.com --output-dir /app/results

You should see the full JSON scan results printed to your terminal, followed by a "Results saved to" message on stderr. The scanner does both: prints to stdout AND writes to the file.

What do these flags do?

  • -v $(pwd)/results:/app/results mounts the results/ folder from your current directory into the container at /app/results.
  • --output-dir /app/results tells the scanner (inside the container) to write the JSON file to that mounted path.
  • Docker creates the host results/ directory automatically if it does not exist.

Seeing a permission denied error?

  • Make sure you are running the command from inside the accessagent-scanner/ project directory so $(pwd)/results resolves correctly.
  • On some systems, Docker needs explicit permission to bind-mount paths. Check that Docker Desktop's file sharing settings include your project folder.

Help me debug a Docker volume permission error for my AccessAgent scanner

Inspect the results on your host

The container wrote the file and exited. The JSON file should now be sitting in your project directory, ready for any downstream process to read.

  • Check that the results file appeared on your host by running:
ls results/

You should see a file named something like scan_20260711_143022.json (the timestamp matches when you ran the scan).

  • Inspect the contents by running:
cat results/scan_*.json | head -20

You should see the same structured JSON you saw in your terminal output, with the url, scanned_at, violations_count, and violations fields.

No file in results/?

  • Confirm you passed --output-dir /app/results at the end of the docker run command. Without this flag, the scanner only prints to stdout.
  • Make sure you rebuilt the image after editing scanner.py. The old image does not have the --output-dir flag.

Help me figure out why my Docker volume mount is not producing a results file

Your scanner now writes persistent results that survive after the container exits. Next up, you will wire the scanner into a docker-compose stack alongside Postgres so every scan is stored in a real database.

Add Postgres with Docker Compose

File-based output served you well for passing scan results to the host machine. But your future Triage and Fix-Gen agents need to query violations by severity, URL, and timestamp.

In this step, you will create a Docker Compose stack that runs Postgres alongside your scanner. Docker Compose orchestrates multiple containers from a single YAML file, so you get a real database without installing anything locally.

In this step, get ready to:
  • Define a two-service Docker Compose stack with Postgres and your scanner.
  • Write a persistence module that saves scan results to Postgres.
  • Wire the scanner to persist results when a database is available.
Create the Docker Compose file

Docker Compose lets you define multiple containers and their relationships in a single file called docker-compose.yml. Your stack needs two services: a Postgres database and your scanner.

  • Create a new file called docker-compose.yml in your accessagent-scanner folder with the following content:
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: scanner
      POSTGRES_PASSWORD: scanner_pass
      POSTGRES_DB: accessagent
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U scanner -d accessagent"]
      interval: 5s
      timeout: 3s
      retries: 5

  scanner:
    build: .
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: "postgresql://scanner:scanner_pass@db:5432/accessagent"
    volumes:
      - ./results:/app/results
    init: true
    ipc: host

What does this file do?

  • The db service pulls the official postgres:16 image and creates a database called accessagent with a user named scanner.
  • The healthcheck uses pg_isready to confirm Postgres is accepting connections before the scanner starts.
  • The scanner service builds from your existing Dockerfile and waits for the database to be healthy via depends_on.
  • The init: true and ipc: host directives are the compose equivalents of the --init and --ipc=host flags you used in previous steps for Chromium stability.
  • Save docker-compose.yml.
  • Start just the database service to confirm it boots cleanly by running:
docker compose up db -d
  • Check the database container's status by running:
docker compose ps

You should see the db service listed with a status of Up and (healthy) in the output.

Not seeing a healthy status?

  • If the status shows (health: starting), wait a few seconds and run docker compose ps again. The health check retries up to 5 times.
  • Make sure Docker Desktop is running. If you see a connection error, open Docker Desktop from your system tray.
  • If port 5432 is already in use, stop any local Postgres instance before running the compose command.

Help me debug my Docker Compose Postgres container not starting.

Write the persistence module

With Postgres running, you need a Python module that connects to the database, creates the table schema, and inserts scan results. This keeps database logic separate from scanning logic.

  • Create a new file called persist.py in your accessagent-scanner folder and add the following code:
import json

import psycopg2

from models import ScanResult


def save_scan_result(database_url: str, result: ScanResult) -> None:
    conn = psycopg2.connect(database_url)
    cur = conn.cursor()

    # Create the scans table if it doesn't exist yet
    cur.execute("""
        CREATE TABLE IF NOT EXISTS scans (
            id SERIAL PRIMARY KEY,
            url TEXT NOT NULL,
            scanned_at TIMESTAMPTZ NOT NULL,
            violations_count INTEGER NOT NULL,
            results_json JSONB NOT NULL
        )
    """)

What does this code do?

  • The save_scan_result function takes a database connection string and a ScanResult object from your models.
  • It connects to Postgres using psycopg2 and creates the scans table on first run. The IF NOT EXISTS clause means it safely skips creation on subsequent runs.
  • The table stores the scan URL, timestamp, violation count, and the full results as a JSONB column for flexible querying.
  • Still in persist.py, add the insert logic and cleanup directly below the code you just wrote:
    # Insert the scan result into the table
    cur.execute(
        """
        INSERT INTO scans (url, scanned_at, violations_count, results_json)
        VALUES (%s, %s, %s, %s)
        """,
        (
            result.url,
            result.scanned_at,
            result.violations_count,
            json.loads(result.model_dump_json()),
        ),
    )

    conn.commit()
    cur.close()
    conn.close()

What does this code do?

  • The second cur.execute inserts a row with the scan URL, timestamp, violation count, and the full JSON results.
  • Using %s placeholders with a tuple of values prevents SQL injection. psycopg2 handles the escaping.
  • The conn.commit() call saves the transaction, then the cursor and connection are closed to free resources.
  • Save persist.py.
  • Verify the module imports correctly by running:
python -c "from persist import save_scan_result; print('OK')"

You should see OK printed with no errors.

Seeing an ImportError?

  • Make sure your virtual environment is activated. You should see (.venv) in your terminal prompt.
  • Check that persist.py is in the same folder as models.py. The import from models import ScanResult requires both files to be in the same directory.

Help me fix my persist.py import error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved persist.py and the import test printed OK.

ⓧ I'd like to double check the full code

Your persist.py should have two sections you built above, in this order:

  • Imports (json, psycopg2, ScanResult) and the save_scan_result function definition with the CREATE TABLE query.
  • The INSERT INTO scans query followed by conn.commit(), cur.close(), and conn.close().

Scroll up and confirm each section is present in that order.

Connect the scanner to Postgres and run the full stack

The persistence module is ready, but scanner.py does not call it yet. You need to add a check for a DATABASE_URL environment variable so the scanner persists results whenever a database is available.

  • In scanner.py, find the closing lines of the main() function. Locate this block:
    if args.output_dir:
        output_path = Path(args.output_dir)
        output_path.mkdir(parents=True, exist_ok=True)
        timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
        file_path = output_path / f"scan_{timestamp}.json"
        file_path.write_text(result_json)
        print(f"\nResults saved to: {file_path}", file=sys.stderr)
  • Add the following code directly below that if args.output_dir block, before the if __name__ == "__main__": line:
    # Persist to database if DATABASE_URL is set
    database_url = os.environ.get("DATABASE_URL")
    if database_url:
        from persist import save_scan_result

        save_scan_result(database_url, result)
        print("Results persisted to database.", file=sys.stderr)

What does this code do?

  • It reads the DATABASE_URL environment variable. When running locally without Compose, this variable is unset, so persistence is skipped.
  • Inside Docker Compose, the docker-compose.yml injects DATABASE_URL automatically, so the scanner writes to Postgres every time it runs in the stack.
  • The from persist import save_scan_result is a lazy import. It only loads psycopg2 when a database is actually available.
  • Save scanner.py.
  • Stop the database container you started earlier by running:
docker compose down

Before you run the full stack, think about what you expect to happen. The scanner will build a fresh image (picking up persist.py and the updated scanner.py), wait for Postgres to be healthy, scan a URL, and attempt to write to the database. Do you think it will succeed on the first try?

  • Start the full stack by running:
docker compose up

What should I see?

Docker Compose will first start the db service and wait for it to pass its health check. Then it builds the scanner image and starts the scanner container.

You will see the scanner's JSON output followed by Results persisted to database. printed to stderr. The scanner container then exits, but the database keeps running.

Pass a URL to the scanner service

By default, docker compose up does not pass a URL argument to the scanner. You need to add the URL as a command override. Stop the stack with Ctrl+C, then run:

docker compose run --rm scanner https://example.com --output-dir /app/results

This runs a one-off scanner container with the URL argument, then removes the container when done.

Now verify that the scan result actually landed in Postgres.

  • Query the database by running:
docker compose exec db psql -U scanner -d accessagent -c "SELECT id, url, violations_count FROM scans;"

You should see a table row showing the scan ID, the URL you scanned, and the number of violations found.

Not seeing any rows in the query?

  • Check the scanner container logs for errors. If you see a connection refused message, the scanner may have started before Postgres was ready. Run docker compose down and docker compose up again.
  • Make sure the DATABASE_URL in docker-compose.yml matches the credentials in the db service's environment block. The user is scanner, the password is scanner_pass, and the database is accessagent.
  • If you used docker compose up without a URL argument, the scanner exits with an error because no URL was provided. Use docker compose run --rm scanner https://example.com instead.

Help me debug why my scan results are not appearing in Postgres.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files and your psql query returned at least one row.

ⓧ I'd like to double check the full code

Your scanner.py should now have these sections in order:

  • Imports at the top (argparse, json, os, sys, datetime, Path, Axe, sync_playwright, ScanResult).
  • The scan_url function that launches Chromium and runs axe-core.
  • The main() function with argument parsing, the scan call, stdout printing, the --output-dir file write, and the new DATABASE_URL persistence block.
  • The if __name__ == "__main__": guard at the bottom.

Your docker-compose.yml should match the 26-line file you created earlier with both the db and scanner services.

Your scanner now runs inside a fully orchestrated stack. Postgres stores every scan result, making it queryable by any downstream agent or dashboard. Next up, you will tackle the Secret Mission and shrink your Docker image with a multi-stage build.

Secret mission

Shrink Your Docker Image with a Multi-Stage Build

Your Docker image works, but it's over 2GB. That's a lot of wasted space from build tools, pip cache, and development headers sitting in the final image doing nothing. Can you split the Dockerfile into two stages and cut out the dead weight?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker, so there are no ongoing cloud costs.

Resources you used:

  • Docker containers: scanner and db services running via docker-compose.
  • Docker volume: Postgres data volume storing your scan results.
  • Docker image: accessagent-scanner built from your Dockerfile.
  • Local project files: the accessagent-scanner/ folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building on the AccessAgent platform or plan to extend the scanner soon.

  • Your docker-compose stack stays up. Postgres data persists in the named volume between container restarts.
  • You can re-run scans at any time with docker compose run scanner https://any-url.com.
  • The accessagent-scanner image remains cached locally for instant container starts.

Pause - I'll come back to this later

Shut down running containers to free up memory and CPU, but keep all data and images intact so you can resume later.

  • Stop the running containers by running this command from your accessagent-scanner/ folder:
docker compose stop
  • This stops the containers without deleting your Postgres data volume. When you are ready to resume, run docker compose start from the same folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes containers, volumes, images, and local files permanently.

  • Stop and remove all containers and the Postgres data volume by running this from your accessagent-scanner/ folder:
docker compose down -v
  • Remove the Docker image:
docker rmi accessagent-scanner
  • Delete the project folder from your Desktop. On macOS or Linux:
rm -rf ~/Desktop/accessagent-scanner
  • On Windows (PowerShell):
Remove-Item -Recurse -Force ~\Desktop\accessagent-scanner
  • After these commands, all project traces are removed from your machine. Docker Desktop itself remains installed.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-ready accessibility scanner, packaged it in Docker, and wired it into a multi-container stack with persistent storage.

You've learned how to:

  • Built a Python CLI scanner that detects WCAG violations on any URL using axe-core via Playwright, outputting structured JSON with severity, affected elements, and help URLs.
  • Wrote a Dockerfile from scratch and learned Docker fundamentals: images, containers, build context, layers, .dockerignore, --init, --ipc=host, and Docker volumes to bridge container output to your host filesystem.
  • Created a docker-compose stack running the scanner alongside Postgres, persisting every scan to a queryable database so you can track accessibility improvements over time.
  • Secret Mission: Optimized your Docker image size with a multi-stage build, separating install-time dependencies from runtime to ship a leaner final image.

Ready to quiz yourself?