Chapter 09
Docker
⏱ 20 minutes · You will build a two-stage image that obfuscates in the first stage and ships only the protected build, then inspect it the way someone who receives the image would.
The idea
Stage 1: builder (python:3.12-slim) Stage 2: runtime (python:3.12-slim)
requirements.txt → /opt/venv ───────────────→ /opt/venv (dependencies)
pip install pyarmor (no PyArmor)
COPY app/ (source) (no source)
scripts/obfuscate.py → build/protected ──────→ /srv/app (protected code + runtime)
scripts/verify.py USER app, HEALTHCHECK, CMD python -m appOnly what is explicitly copied from the builder reaches the final image. The source, PyArmor and the builder's layers are left behind.
Read the Dockerfile now. It is short and every line has a comment. Points to
notice:
- Same base image in both stages. The protected build only runs on the Python version that built it.
pip install --require-hashes. Every dependency must match the hash inrequirements.txt.- PyArmor is installed outside
/opt/venv, so it cannot leak into the runtime image. obfuscate.py && verify.pyfail the build if PyArmor refuses a module or the output is not fully obfuscated.- A non-root user (
app, uid 10001) runs the app. The code is owned by root, so the app cannot change it.
And its ignore file, Dockerfile.dockerignore, is an
allowlist: only the Python parts of app/, two scripts and requirements.txt are sent to
Docker. .git, .env, tests/, workshop/ and the website in app/frontend/ never even reach
the builder.
Build the image
docker build -f app/backend/Dockerfile -t pyarmor-demo:local .What it does: runs both stages and tags the result pyarmor-demo:local.
Why: obfuscating inside the builder means the build happens on Linux with the exact Python
the image runs, wherever you start it from.
Expected: the PyArmor log and the verification ticks scroll past inside the build, then
naming to docker.io/library/pyarmor-demo:local.
#15 0.960 INFO obfuscate scripts OK
#15 1.125 ✓ every module is obfuscated and present
#15 1.125 ✓ the PyArmor runtime is present
#15 1.125 ✓ no function names, constant names or long strings found as plain textRun it
docker compose up -d --buildWhat it does: starts two containers from docker-compose.yml:
api (the protected image you just built, on port 8300) and web (this workshop's website on
port 3300, served by nginx, which forwards /api to the api container). Both are published on
localhost only, with a read-only filesystem, all Linux capabilities dropped, and
no-new-privileges.
Why: these settings cost nothing and limit what a compromised process could do.
Expected: Container pyarmor-demo-api-1 Healthy, then Container pyarmor-demo-web-1 Started.
Open http://localhost:3300/lab/: the badge says Protected build.
uv run python scripts/smoke.py --expect-build protected
docker compose psExpected:
Smoke test passed, andapishowsUp ... (healthy). The health status comes from theHEALTHCHECKin the Dockerfile, which calls/health.
Inspect the image: what does the recipient actually get?
Imagine you shipped this image to a customer. What can they see?
The files
docker run --rm --entrypoint find pyarmor-demo:local /srv/app -type fWhat it does: starts a throwaway container that lists every file in the app folder, instead
of starting the API.
Why: anyone who has an image can do this. It needs no special tools.
Expected: 16 obfuscated .py files under /srv/app/app/ and the runtime
(pyarmor_runtime_000000/__init__.py and pyarmor_runtime.so). Nothing else.
No source, no PyArmor, no build leftovers
docker run --rm --entrypoint sh pyarmor-demo:local -c 'ls /src; which pyarmor; head -c 150 /srv/app/app/backend/business_logic/scoring.py'What it does: looks for the builder's source folder and the PyArmor CLI, and prints the start of the protected scoring module. Expected:
ls: cannot access '/src': No such file or directory
# Pyarmor 9.2.7 (trial), 000000, non-profits, 2026-10-08T09:17:16.748765
from pyarmor_runtime_000000 import __pyarmor__
__pyarmor__(__name__, __file__, b'PY0000...Notice what the header gives away: the PyArmor version, the license type (trial) and the
build time. That is not secret, but it is information: it tells an analyst which tool and version
to study.
The layers
docker history pyarmor-demo:localWhat it does: lists the layers of the final image and the instruction that made each one.
Why: layers are how images are stored and shipped. Anything ever added in a layer of the
final image stays inside it, even if a later step deletes it.
Expected: the runtime stage only: COPY /opt/venv (~27 MB), COPY /src/build/protected
(~0.9 MB), the user, HEALTHCHECK and CMD. None of the builder's steps (pip install pyarmor,
COPY app, the obfuscation run) appear, because they were never part of this image.
The configuration
docker image inspect pyarmor-demo:local --format '{{json .Config.Env}}'What it does: prints the environment variables baked into the image.
Why: this is where people accidentally ship secrets.
Expected: PATH, PYTHON_VERSION, and the four PYARMOR_DEMO_* settings. All harmless.
Inside the running container
docker compose exec api sh -c 'id; touch /srv/app/test'What it does: shows which user the app runs as, and tries to write into the code folder. Expected:
uid=10001(app) gid=10001(app) groups=10001(app)
touch: cannot touch '/srv/app/test': Read-only file systemHold on to that thought: docker compose exec gave you a shell inside the running app's
container. That is scenario C from chapter 00, and chapter 11 is about what it means.
Image size
docker image ls pyarmor-demo:localExpected: about 150 MB: the slim Python base image (~120 MB), the dependencies (~27 MB) and the protected app (under 1 MB).
Stop the stack when you are done:
docker compose down