Validate-First: AsciiDoc projects with devcontainer and docker-compose
The previous post covered a single container for Jekyll + AsciiDoc. This time things get more interesting: two containers working together — one validates, the other builds.
The problem: docToolchain swallows errors
docToolchain is the reference tool for arc42/req42 documentation. It produces HTML and PDF from AsciiDoc sources — reliably, battle-tested, well integrated.
What it does not do: abort on broken AsciiDoc. Missing includes, unresolved cross-references, syntax errors — docToolchain reports them as warnings and keeps building. You end up with a PDF full of placeholders where content should be.
In a CI pipeline that is fatal. The build is "green", the artefact is broken.
The solution: validation as a gate
The ghcr.io/tpo42/adoc container (the one from the
Jekyll post) ships not
only the AsciiDoc toolchain but also a validate command. Under the
hood it runs asciidoctor in strict mode: missing files, broken
references, syntax errors — everything becomes an error, not a warning.
The idea: validate before the build. Only when the sources are clean may docToolchain proceed.
Two containers, one workflow
Instead of cramming everything into a single container we separate concerns:
| Container | Job | Image |
|---|---|---|
adoc |
AsciiDoc validation, development toolchain |
|
doctoolchain |
PDF and HTML builds |
|
The glue is a docker-compose.yml:
services:
adoc:
image: ghcr.io/tpo42/adoc:latest
volumes:
- .:/workspace
command: ["sleep", "infinity"]
doctoolchain:
image: doctoolchain/doctoolchain:v3.4.2
platform: linux/amd64
volumes:
- .:/project
- dtc-gradle-cache:/home/dtcuser/.gradle
entrypoint: ["sleep", "infinity"]
environment:
DTC_HEADLESS: "true"
DTC_OPTS: >-
-PmainConfigFile=docToolchainConfig.groovy
--warning-mode=none -Dfile.encoding=UTF-8
volumes:
dtc-gradle-cache:
Both containers run permanently (sleep infinity). Commands go in via
docker compose exec — no container-start overhead per invocation.
The devcontainer
The .devcontainer/devcontainer.json references the same Compose file:
{
"name": "my-docs-project",
"dockerComposeFile": "../docker-compose.yml",
"service": "adoc",
"workspaceFolder": "/workspace",
"shutdownAction": "stopCompose"
}
devcontainer up starts both containers. The adoc container is the
primary — that is where you edit and validate. The doctoolchain
container runs as a sidecar for builds.
The workflow
# Start the containers
docker compose up -d
# Validate
docker compose exec adoc validate \
-i Docs/req42-container-toolchain.adoc \
-i Docs/arc42-container-toolchain.adoc \
--strict --verbose
# Build
docker compose exec doctoolchain \
bash -c 'doctoolchain . generatePDF $DTC_OPTS'
docker compose exec doctoolchain \
bash -c 'doctoolchain . generateHTML $DTC_OPTS'
Or with the devcontainer CLI — at least for the adoc container:
devcontainer exec --workspace-folder . validate -i Docs/*.adoc --strict
For the doctoolchain container this does not (yet) work:
devcontainer exec only knows the primary service — there is no
--service flag. For builds you still need docker compose exec.
The side-effect: 40 seconds → 2 seconds
docToolchain is built on Gradle. Every docker run starts a fresh JVM,
initialises the Gradle daemon, loads plugins — roughly 40 seconds
before anything is actually built.
The dtcw wrapper deliberately uses --no-daemon because the daemon
dies anyway in one-shot containers. But when the container stays alive
(sleep infinity + docker compose exec), the daemon stays warm:
| First call | Subsequent calls | |
|---|---|---|
|
~40s |
~40s |
|
~15s |
~2s |
The Gradle cache lives in a named volume (dtc-gradle-cache) and
survives docker compose down / up cycles. Only
docker compose down -v wipes it.
This is not a trick — it is simply how Gradle was designed to work.
docker run forces the cold start that Gradle is meant to avoid.
CI: same images, different flow
In a CI pipeline (Jenkins, GitLab CI, …) the containers run as one-shot invocations. The warm daemon does not help there — but validation as a gate is what matters:
Stage: Validate -> Stage: Build PDF -> Stage: Build HTML -> Archive
(ghcr.io/tpo42/adoc) (doctoolchain) (doctoolchain)
If Validate fails the build stages do not run. No broken PDF in the artefacts.
What I learned along the way
sleep infinityis not a crutch-
For task-runner containers (validate, build) a permanently running container sounds wasteful. But when the container is stateful (Gradle daemon, gem cache), the persistent setup is the performant option.
DTC_OPTSas an environment variable-
docToolchain expects various flags (
-PmainConfigFile=…,--warning-mode=none, …). Defining them asDTC_OPTSin thedocker-compose.ymlsaves you from repeating them on every call:bash -c 'doctoolchain . generatePDF $DTC_OPTS'. devcontainer exechas limits-
The devcontainer CLI can only exec into the primary service. For sidecar containers you need
docker compose exec. A--serviceflag would make a sensible feature request for the devcontainers/cli.
Outlook
This setup covers only requirements and architecture documentation — there is no source code to compile. A firmware project would add at least a third container: the cross-compiler. And for projects with vendor-specific code generation (STM32CubeMX) a fourth, passing artefacts to the compile container via a shared volume.
The docker-compose.yml scales linearly for this — one service per
concern, the same Compose file for devcontainer and CI.
But more on that when we get there.