Guide
Automating Reporting WorkflowsDeep dive

Run a Python Excel Report in Docker

Containerise a reporting job: a small Dockerfile, pinned dependencies, timezone and locale, mounted volumes for input and output, secrets, and scheduling the container.

A reporting job that runs on one person's laptop is not automated. Containerising it fixes the two failures that follow a script into production — a different library version and a missing system dependency — and makes the job portable across a laptop, a server and a CI runner. Because pandas, openpyxl and xlsxwriter are pure Python, the image stays small; the interesting parts are timezone, file ownership on mounted volumes, and getting secrets in without baking them into a layer. This guide builds and schedules that container. It extends Scheduling Python Excel Scripts with Cron.

What lives inside the image and what is mounted at run time The image holds the interpreter, pinned libraries and the report code, while input data, output files and secrets arrive at run time through mounts and environment variables. baked into the image supplied at run time Python interpreter, pinned libraries the report code and its templates timezone data and locale input files, via a mounted volume output directory, also mounted secrets, as environment variables

Prerequisites

Docker, and a report script that already runs locally. The example assumes this layout:

Text
reporting/
  Dockerfile
  requirements.txt
  generate_report.py

Pin the dependencies

Version drift is the problem containers exist to solve, so pin exactly — a range defeats the purpose:

Text
pandas==2.2.3
openpyxl==3.1.5
XlsxWriter==3.2.0
python-calamine==0.3.1

Whatever produced the workbook that was signed off should be what runs next month. If you use a lock file from Poetry or uv, copy that instead and install from it.

Write the Dockerfile

Dockerfile
FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    TZ=Europe/London

RUN apt-get update \
 && apt-get install -y --no-install-recommends tzdata \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

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

COPY generate_report.py .

RUN useradd --create-home --uid 10001 reporter
USER reporter

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

Four choices are doing real work here. PYTHONUNBUFFERED=1 makes logs appear as they happen rather than when the process exits — without it a container that dies mid-run appears to have printed nothing. Copying requirements.txt before the code means a code change does not reinstall every dependency. tzdata plus TZ stops dates landing a day out. And running as a non-root user keeps the files written to a mounted volume from being owned by root.

Run it with mounted volumes

Bash
docker build -t sales-report:2026-08 .

docker run --rm \
  -v "$PWD/data:/data:ro" \
  -v "$PWD/out:/out" \
  -e REPORT_DATE=2026-08-27 \
  sales-report:2026-08 --input /data/sales.xlsx --output /out/summary.xlsx

Mounting the input read-only is a habit worth keeping: a bug that would have overwritten the source data fails instead. --rm removes the container when it exits, so a nightly job does not accumulate hundreds of stopped containers.

One run of the report container The scheduler starts a container, which reads from a read-only input mount, writes the workbook to an output mount, logs to stdout and exits with a status code. Start, produce, exit — nothing resident scheduler container pinned libraries runs as reporter /data (read only) /out stdout logs exit 0

Keep file ownership sane

Files written to a bind mount keep the container user's numeric id, which on the host may belong to nobody. Pass your own ids at run time:

Bash
docker run --rm \
  --user "$(id -u):$(id -g)" \
  -v "$PWD/out:/out" \
  sales-report:2026-08 --output /out/summary.xlsx

This overrides the image's USER for that run and makes the output owned by you. On a server, use the id of the account that consumes the files — the one running the upload or the mail step.

Pass secrets without baking them in

Anything in a RUN or ENV line is readable by anyone who pulls the image. Supply credentials at run time:

Bash
docker run --rm \
  --env-file /etc/reporting/report.env \
  -v /etc/secrets/service-account.json:/secrets/sa.json:ro \
  -e GOOGLE_APPLICATION_CREDENTIALS=/secrets/sa.json \
  sales-report:2026-08

An env file readable only by the scheduling user, plus a read-only mount for key files, covers most cases without an orchestrator's secret store. The environment-variable pattern is the same one used in Keep Excel report settings in a config file.

Add LibreOffice only if you need it

PDF export and formula recalculation need an office suite, which changes the size of the image considerably:

Dockerfile
RUN apt-get update \
 && apt-get install -y --no-install-recommends libreoffice-calc fonts-dejavu \
 && rm -rf /var/lib/apt/lists/*

Install the fonts too — a container without them renders a PDF in a fallback face, and the output looks subtly wrong in a way that is hard to diagnose. If only some reports need PDF, build two images from the same code rather than putting several hundred megabytes into every run. The conversion itself is in Convert an Excel file to PDF with Python.

Schedule it

Cron on the host is the simplest scheduler, and it works because the container exits:

Bash
30 6 * * 1-5 /usr/bin/docker run --rm --env-file /etc/reporting/report.env \
  -v /srv/reports/data:/data:ro -v /srv/reports/out:/out \
  sales-report:2026-08 >> /var/log/sales-report.log 2>&1

A systemd timer gives better logging and dependency handling; Kubernetes offers a CronJob with retries and history built in. The cron environment caveats still apply — absolute paths for everything, since cron's PATH is minimal. See Scheduling Python Excel Scripts with Cron and, for a runner you do not maintain, Run a Python Excel report in GitHub Actions.

Make the container report its own health

A scheduled container succeeds or fails by its exit code, so the script has to use it deliberately. Three states are worth distinguishing, because a scheduler can act on each differently:

Python
"""generate_report.py — exit codes a scheduler can act on."""
import sys

EXIT_OK, EXIT_NO_DATA, EXIT_FAILED = 0, 3, 1

def main() -> int:
    rows = load_input()
    if not rows:
        print("no rows for the period — nothing to produce", file=sys.stderr)
        return EXIT_NO_DATA
    try:
        build_workbook(rows)
    except Exception as exc:                     # noqa: BLE001
        print(f"report failed: {type(exc).__name__}: {exc}", file=sys.stderr)
        return EXIT_FAILED
    print("report written")
    return EXIT_OK

if __name__ == "__main__":
    raise SystemExit(main())

A distinct code for "ran fine, there was nothing to do" is the one people forget, and it is the difference between a quiet Monday and a pager alert every bank holiday. Wire it into the scheduler so that code 3 is logged and ignored while code 1 raises an alert.

Three exit codes and what the scheduler does with each Zero means the workbook was produced, three means there was no data and no alert is needed, and one means the run failed and someone should be told. Not every non-success is a failure exit 0 workbook written deliver it exit 3 no data this period log it, no alert exit 1 the run failed retry, then alert

Test the image before it goes near a schedule

Build and run the container against a fixture once, in the same shape the scheduler will use it, and assert on the file it produces:

Bash
docker build -t sales-report:test .
docker run --rm --user "$(id -u):$(id -g)" \
  -v "$PWD/tests/fixtures:/data:ro" -v "$PWD/tmp:/out" \
  sales-report:test --input /data/sales.xlsx --output /out/summary.xlsx

python - <<'CHECK'
import pandas as pd
df = pd.read_excel("tmp/summary.xlsx", engine="openpyxl")
assert list(df.columns) == ["region", "revenue"], df.columns
assert len(df) == 4, len(df)
print("container output looks right")
CHECK

Running this in CI on every change to the Dockerfile or the requirements catches the two failures that only appear in the image: a library that resolved differently on a rebuild, and a missing system package the local machine happened to have.

Common pitfalls and gotchas

  • Root-owned output. Run as a non-root user and pass --user for bind mounts.
  • UTC dates. Install tzdata and set TZ, or a report dated at midnight lands on the wrong day.
  • Buffered logs. Without PYTHONUNBUFFERED=1, a crashed run appears to have logged nothing.
  • latest tags. Tag images by date or version so a failed run can be reproduced exactly.
  • Secrets in layers. Deleting a file in a later layer does not remove it from the image.

Performance and scale notes

A slim Python image with these libraries lands around 250 MB, and container start-up costs well under a second — negligible against a report that takes minutes. Memory is the constraint worth setting explicitly: pass --memory so a runaway job is killed rather than exhausting the host, and remember that xlsxwriter holds the whole workbook in memory. For a batch of many reports, run several containers concurrently rather than looping inside one, so a single failure does not take the batch with it and the scheduler can retry just that report. The parallel-processing considerations are the same as in Process multiple Excel files in parallel with Python.

Conclusion

Containerising an Excel report is a small Dockerfile and three habits: pin the dependencies exactly, run as a non-root user with an explicit timezone, and keep data and secrets outside the image. The container starts, produces the workbook onto a mounted volume, logs to stdout and exits — which is exactly the shape cron, systemd or a Kubernetes CronJob wants. Add LibreOffice only when PDF or recalculation genuinely require it, and build it as a separate image so ordinary runs stay small.

Frequently asked questions

Do I need Excel or LibreOffice in the image? Not for pandas, openpyxl or xlsxwriter — they write .xlsx in pure Python. You only need LibreOffice if the job converts to PDF or must recalculate formulas, and it adds several hundred megabytes.

Why does my container write files nobody can read? The process runs as root by default, so output on a mounted volume is owned by root. Create a user in the image and pass matching uid and gid, or fix ownership after the run.

Why are the dates in my report a day out? The container's clock is UTC unless told otherwise. Set the TZ environment variable, and install tzdata if the base image lacks it.

Should the container run continuously or once per report? Once per report. A container that starts, produces the workbook and exits is easier to schedule, retry and reason about than a resident process holding its own scheduler.

How do I get credentials into the container? Environment variables from the orchestrator, or a mounted secrets file. Never bake them into the image — anyone who can pull it can read them.