[{"data":1,"prerenderedAt":1500},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-docker":3,"surround:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-docker":1491},{"id":4,"title":5,"body":6,"dateModified":1469,"datePublished":1469,"description":1470,"extension":1471,"faq":1472,"meta":1483,"navigation":170,"path":1484,"seo":1485,"slug":1487,"stem":1488,"type":1489,"__hash__":1490},"docs\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-docker\u002Findex.md","Run a Python Excel Report in Docker",{"type":7,"value":8,"toc":1452},"minimark",[9,19,113,118,121,131,135,138,144,147,151,280,299,303,405,412,546,550,553,615,622,626,637,685,692,696,699,717,724,728,731,794,808,812,815,1082,1085,1150,1154,1157,1285,1288,1292,1343,1347,1358,1362,1365,1369,1379,1385,1397,1403,1409,1413,1448],[10,11,12,13,18],"p",{},"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 ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002F","Scheduling Python Excel Scripts with Cron",".",[20,21,29,30,29,34,29,38,29,45,29,52,29,57,29,65,29,73,29,78,29,81,29,85,29,88,29,92,29,96,29,101,29,103,29,106,29,109],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"0 0 760 230","img",[25,26],"dk-t","dk-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:760px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[31,32,33],"title",{"id":25},"What lives inside the image and what is mounted at run time",[35,36,37],"desc",{"id":26},"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.",[39,40],"rect",{"x":41,"y":41,"width":42,"height":43,"fill":44},"0","760","230","#ffffff",[46,47,51],"text",{"x":48,"y":49,"style":50},"196","28","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","baked into the image",[46,53,56],{"x":54,"y":49,"style":55},"566","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","supplied at run time",[58,59],"line",{"x1":60,"y1":61,"x2":60,"y2":62,"stroke":63,"style":64},"380","40","212","var(--line,#cdd5e6)","stroke-width:1px",[39,66],{"x":67,"y":68,"width":69,"height":70,"rx":71,"fill":72,"stroke":63},"30","46","332","42","9","#ebebfd",[46,74,77],{"x":48,"y":75,"style":76},"72","font-size:12px;fill:var(--text,#172033);text-anchor:middle","Python interpreter, pinned libraries",[39,79],{"x":67,"y":80,"width":69,"height":70,"rx":71,"fill":72,"stroke":63},"96",[46,82,84],{"x":48,"y":83,"style":76},"122","the report code and its templates",[39,86],{"x":67,"y":87,"width":69,"height":70,"rx":71,"fill":72,"stroke":63},"146",[46,89,91],{"x":48,"y":90,"style":76},"172","timezone data and locale",[39,93],{"x":94,"y":68,"width":69,"height":70,"rx":71,"fill":95,"stroke":63},"398","#d9f4f1",[46,97,100],{"x":98,"y":75,"style":99},"564","font-size:12px;fill:var(--teal-ink,#0b6157);text-anchor:middle","input files, via a mounted volume",[39,102],{"x":94,"y":80,"width":69,"height":70,"rx":71,"fill":95,"stroke":63},[46,104,105],{"x":98,"y":83,"style":99},"output directory, also mounted",[39,107],{"x":94,"y":87,"width":69,"height":70,"rx":71,"fill":108,"stroke":63},"#fee8f2",[46,110,112],{"x":98,"y":90,"style":111},"font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","secrets, as environment variables",[114,115,117],"h2",{"id":116},"prerequisites","Prerequisites",[10,119,120],{},"Docker, and a report script that already runs locally. The example assumes this layout:",[122,123,128],"pre",{"className":124,"code":126,"language":46,"meta":127},[125],"language-text","reporting\u002F\n  Dockerfile\n  requirements.txt\n  generate_report.py\n","",[129,130,126],"code",{"__ignoreMap":127},[114,132,134],{"id":133},"pin-the-dependencies","Pin the dependencies",[10,136,137],{},"Version drift is the problem containers exist to solve, so pin exactly — a range defeats the purpose:",[122,139,142],{"className":140,"code":141,"language":46,"meta":127},[125],"pandas==2.2.3\nopenpyxl==3.1.5\nXlsxWriter==3.2.0\npython-calamine==0.3.1\n",[129,143,141],{"__ignoreMap":127},[10,145,146],{},"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.",[114,148,150],{"id":149},"write-the-dockerfile","Write the Dockerfile",[122,152,156],{"className":153,"code":154,"language":155,"meta":127,"style":127},"language-dockerfile shiki shiki-themes github-light github-dark-high-contrast","FROM python:3.12-slim\n\nENV PYTHONUNBUFFERED=1 \\\n    PYTHONDONTWRITEBYTECODE=1 \\\n    TZ=Europe\u002FLondon\n\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends tzdata \\\n && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n\nWORKDIR \u002Fapp\n\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY generate_report.py .\n\nRUN useradd --create-home --uid 10001 reporter\nUSER reporter\n\nENTRYPOINT [\"python\", \"generate_report.py\"]\n","dockerfile",[129,157,158,165,172,178,184,190,195,201,207,213,218,224,229,235,241,246,252,257,263,269,274],{"__ignoreMap":127},[159,160,162],"span",{"class":58,"line":161},1,[159,163,164],{},"FROM python:3.12-slim\n",[159,166,168],{"class":58,"line":167},2,[159,169,171],{"emptyLinePlaceholder":170},true,"\n",[159,173,175],{"class":58,"line":174},3,[159,176,177],{},"ENV PYTHONUNBUFFERED=1 \\\n",[159,179,181],{"class":58,"line":180},4,[159,182,183],{},"    PYTHONDONTWRITEBYTECODE=1 \\\n",[159,185,187],{"class":58,"line":186},5,[159,188,189],{},"    TZ=Europe\u002FLondon\n",[159,191,193],{"class":58,"line":192},6,[159,194,171],{"emptyLinePlaceholder":170},[159,196,198],{"class":58,"line":197},7,[159,199,200],{},"RUN apt-get update \\\n",[159,202,204],{"class":58,"line":203},8,[159,205,206],{}," && apt-get install -y --no-install-recommends tzdata \\\n",[159,208,210],{"class":58,"line":209},9,[159,211,212],{}," && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[159,214,216],{"class":58,"line":215},10,[159,217,171],{"emptyLinePlaceholder":170},[159,219,221],{"class":58,"line":220},11,[159,222,223],{},"WORKDIR \u002Fapp\n",[159,225,227],{"class":58,"line":226},12,[159,228,171],{"emptyLinePlaceholder":170},[159,230,232],{"class":58,"line":231},13,[159,233,234],{},"COPY requirements.txt .\n",[159,236,238],{"class":58,"line":237},14,[159,239,240],{},"RUN pip install --no-cache-dir -r requirements.txt\n",[159,242,244],{"class":58,"line":243},15,[159,245,171],{"emptyLinePlaceholder":170},[159,247,249],{"class":58,"line":248},16,[159,250,251],{},"COPY generate_report.py .\n",[159,253,255],{"class":58,"line":254},17,[159,256,171],{"emptyLinePlaceholder":170},[159,258,260],{"class":58,"line":259},18,[159,261,262],{},"RUN useradd --create-home --uid 10001 reporter\n",[159,264,266],{"class":58,"line":265},19,[159,267,268],{},"USER reporter\n",[159,270,272],{"class":58,"line":271},20,[159,273,171],{"emptyLinePlaceholder":170},[159,275,277],{"class":58,"line":276},21,[159,278,279],{},"ENTRYPOINT [\"python\", \"generate_report.py\"]\n",[10,281,282,283,286,287,290,291,294,295,298],{},"Four choices are doing real work here. ",[129,284,285],{},"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 ",[129,288,289],{},"requirements.txt"," before the code means a code change does not reinstall every dependency. ",[129,292,293],{},"tzdata"," plus ",[129,296,297],{},"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.",[114,300,302],{"id":301},"run-it-with-mounted-volumes","Run it with mounted volumes",[122,304,308],{"className":305,"code":306,"language":307,"meta":127,"style":127},"language-bash shiki shiki-themes github-light github-dark-high-contrast","docker build -t sales-report:2026-08 .\n\ndocker run --rm \\\n  -v \"$PWD\u002Fdata:\u002Fdata:ro\" \\\n  -v \"$PWD\u002Fout:\u002Fout\" \\\n  -e REPORT_DATE=2026-08-27 \\\n  sales-report:2026-08 --input \u002Fdata\u002Fsales.xlsx --output \u002Fout\u002Fsummary.xlsx\n","bash",[129,309,310,330,334,348,365,378,388],{"__ignoreMap":127},[159,311,312,316,320,324,327],{"class":58,"line":161},[159,313,315],{"class":314},"sMTad","docker",[159,317,319],{"class":318},"srMev"," build",[159,321,323],{"class":322},"sP0c6"," -t",[159,325,326],{"class":318}," sales-report:2026-08",[159,328,329],{"class":318}," .\n",[159,331,332],{"class":58,"line":167},[159,333,171],{"emptyLinePlaceholder":170},[159,335,336,338,341,344],{"class":58,"line":174},[159,337,315],{"class":314},[159,339,340],{"class":318}," run",[159,342,343],{"class":322}," --rm",[159,345,347],{"class":346},"sSjpA"," \\\n",[159,349,350,353,356,360,363],{"class":58,"line":180},[159,351,352],{"class":322},"  -v",[159,354,355],{"class":318}," \"",[159,357,359],{"class":358},"skGVy","$PWD",[159,361,362],{"class":318},"\u002Fdata:\u002Fdata:ro\"",[159,364,347],{"class":346},[159,366,367,369,371,373,376],{"class":58,"line":186},[159,368,352],{"class":322},[159,370,355],{"class":318},[159,372,359],{"class":358},[159,374,375],{"class":318},"\u002Fout:\u002Fout\"",[159,377,347],{"class":346},[159,379,380,383,386],{"class":58,"line":192},[159,381,382],{"class":322},"  -e",[159,384,385],{"class":318}," REPORT_DATE=2026-08-27",[159,387,347],{"class":346},[159,389,390,393,396,399,402],{"class":58,"line":197},[159,391,392],{"class":318},"  sales-report:2026-08",[159,394,395],{"class":322}," --input",[159,397,398],{"class":318}," \u002Fdata\u002Fsales.xlsx",[159,400,401],{"class":322}," --output",[159,403,404],{"class":318}," \u002Fout\u002Fsummary.xlsx\n",[10,406,407,408,411],{},"Mounting the input read-only is a habit worth keeping: a bug that would have overwritten the source data fails instead. ",[129,409,410],{},"--rm"," removes the container when it exits, so a nightly job does not accumulate hundreds of stopped containers.",[20,413,29,418,29,421,29,424,29,427,29,432,29,439,29,445,29,451,29,456,29,463,29,467,29,472,29,476,29,483,29,489,29,492,29,497,29,500,29,504,29,510,29,514,29,519,29,523,29,526,29,530,29,534,29,537,29,542],{"viewBox":414,"role":23,"ariaLabelledBy":415,"xmlns":27,"style":28},"0 0 760 220",[416,417],"dk2-t","dk2-d",[31,419,420],{"id":416},"One run of the report container",[35,422,423],{"id":417},"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.",[39,425],{"x":41,"y":41,"width":42,"height":426,"fill":44},"220",[46,428,431],{"x":60,"y":429,"style":430},"26","font-size:13px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","Start, produce, exit — nothing resident",[39,433],{"x":429,"y":434,"width":435,"height":436,"rx":437,"fill":438,"stroke":63},"82","136","56","11","#f0f2f5",[46,440,444],{"x":441,"y":442,"style":443},"94","116","font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","scheduler",[58,446],{"x1":447,"y1":448,"x2":48,"y2":448,"stroke":449,"style":450},"162","110","var(--brand,#5b5cf0)","stroke-width:2px",[452,453],"polygon",{"points":454,"fill":455},"196,110 186,105 186,115","#5b5cf0",[39,457],{"x":458,"y":459,"width":460,"height":461,"rx":462,"fill":72,"stroke":63},"200","60","180","100","12",[46,464,466],{"x":465,"y":441,"style":50},"290","container",[46,468,471],{"x":465,"y":469,"style":470},"118","font-size:11.5px;fill:var(--muted,#5b6780);text-anchor:middle","pinned libraries",[46,473,475],{"x":465,"y":474,"style":470},"140","runs as reporter",[39,477],{"x":478,"y":479,"width":480,"height":481,"rx":482,"fill":95,"stroke":63},"418","44","150","48","10",[46,484,488],{"x":485,"y":486,"style":487},"493","74","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","\u002Fdata (read only)",[39,490],{"x":478,"y":461,"width":480,"height":481,"rx":482,"fill":491,"stroke":63},"#fdefd8",[46,493,496],{"x":485,"y":494,"style":495},"130","font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","\u002Fout",[39,498],{"x":478,"y":499,"width":480,"height":481,"rx":482,"fill":438,"stroke":63},"156",[46,501,503],{"x":485,"y":502,"style":443},"186","stdout logs",[58,505],{"x1":478,"y1":506,"x2":507,"y2":508,"stroke":509,"style":450},"68","384","88","var(--teal,#0f9488)",[452,511],{"points":512,"fill":513},"384,88 391,77 396,85","#0f766e",[58,515],{"x1":60,"y1":469,"x2":516,"y2":517,"stroke":518,"style":450},"414","124","var(--gold,#b4740a)",[452,520],{"points":521,"fill":522},"414,124 403,120 405,130","#7a4e06",[58,524],{"x1":60,"y1":474,"x2":516,"y2":90,"stroke":525,"style":450},"var(--muted,#5b6780)",[452,527],{"points":528,"fill":529},"414,172 402,169 408,161","#5b6780",[58,531],{"x1":532,"y1":517,"x2":533,"y2":517,"stroke":449,"style":450},"568","608",[452,535],{"points":536,"fill":455},"608,124 598,119 598,129",[39,538],{"x":539,"y":540,"width":517,"height":541,"rx":437,"fill":95,"stroke":63},"612","98","52",[46,543,545],{"x":544,"y":494,"style":487},"674","exit 0",[114,547,549],{"id":548},"keep-file-ownership-sane","Keep file ownership sane",[10,551,552],{},"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:",[122,554,556],{"className":305,"code":555,"language":307,"meta":127,"style":127},"docker run --rm \\\n  --user \"$(id -u):$(id -g)\" \\\n  -v \"$PWD\u002Fout:\u002Fout\" \\\n  sales-report:2026-08 --output \u002Fout\u002Fsummary.xlsx\n",[129,557,558,568,595,607],{"__ignoreMap":127},[159,559,560,562,564,566],{"class":58,"line":161},[159,561,315],{"class":314},[159,563,340],{"class":318},[159,565,343],{"class":322},[159,567,347],{"class":346},[159,569,570,573,576,579,582,585,587,590,593],{"class":58,"line":167},[159,571,572],{"class":322},"  --user",[159,574,575],{"class":318}," \"$(",[159,577,578],{"class":314},"id",[159,580,581],{"class":322}," -u",[159,583,584],{"class":318},"):$(",[159,586,578],{"class":314},[159,588,589],{"class":322}," -g",[159,591,592],{"class":318},")\"",[159,594,347],{"class":346},[159,596,597,599,601,603,605],{"class":58,"line":174},[159,598,352],{"class":322},[159,600,355],{"class":318},[159,602,359],{"class":358},[159,604,375],{"class":318},[159,606,347],{"class":346},[159,608,609,611,613],{"class":58,"line":180},[159,610,392],{"class":318},[159,612,401],{"class":322},[159,614,404],{"class":318},[10,616,617,618,621],{},"This overrides the image's ",[129,619,620],{},"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.",[114,623,625],{"id":624},"pass-secrets-without-baking-them-in","Pass secrets without baking them in",[10,627,628,629,632,633,636],{},"Anything in a ",[129,630,631],{},"RUN"," or ",[129,634,635],{},"ENV"," line is readable by anyone who pulls the image. Supply credentials at run time:",[122,638,640],{"className":305,"code":639,"language":307,"meta":127,"style":127},"docker run --rm \\\n  --env-file \u002Fetc\u002Freporting\u002Freport.env \\\n  -v \u002Fetc\u002Fsecrets\u002Fservice-account.json:\u002Fsecrets\u002Fsa.json:ro \\\n  -e GOOGLE_APPLICATION_CREDENTIALS=\u002Fsecrets\u002Fsa.json \\\n  sales-report:2026-08\n",[129,641,642,652,662,671,680],{"__ignoreMap":127},[159,643,644,646,648,650],{"class":58,"line":161},[159,645,315],{"class":314},[159,647,340],{"class":318},[159,649,343],{"class":322},[159,651,347],{"class":346},[159,653,654,657,660],{"class":58,"line":167},[159,655,656],{"class":322},"  --env-file",[159,658,659],{"class":318}," \u002Fetc\u002Freporting\u002Freport.env",[159,661,347],{"class":346},[159,663,664,666,669],{"class":58,"line":174},[159,665,352],{"class":322},[159,667,668],{"class":318}," \u002Fetc\u002Fsecrets\u002Fservice-account.json:\u002Fsecrets\u002Fsa.json:ro",[159,670,347],{"class":346},[159,672,673,675,678],{"class":58,"line":180},[159,674,382],{"class":322},[159,676,677],{"class":318}," GOOGLE_APPLICATION_CREDENTIALS=\u002Fsecrets\u002Fsa.json",[159,679,347],{"class":346},[159,681,682],{"class":58,"line":186},[159,683,684],{"class":318},"  sales-report:2026-08\n",[10,686,687,688,18],{},"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 ",[14,689,691],{"href":690},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file\u002F","Keep Excel report settings in a config file",[114,693,695],{"id":694},"add-libreoffice-only-if-you-need-it","Add LibreOffice only if you need it",[10,697,698],{},"PDF export and formula recalculation need an office suite, which changes the size of the image considerably:",[122,700,702],{"className":153,"code":701,"language":155,"meta":127,"style":127},"RUN apt-get update \\\n && apt-get install -y --no-install-recommends libreoffice-calc fonts-dejavu \\\n && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[129,703,704,708,713],{"__ignoreMap":127},[159,705,706],{"class":58,"line":161},[159,707,200],{},[159,709,710],{"class":58,"line":167},[159,711,712],{}," && apt-get install -y --no-install-recommends libreoffice-calc fonts-dejavu \\\n",[159,714,715],{"class":58,"line":174},[159,716,212],{},[10,718,719,720,18],{},"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 ",[14,721,723],{"href":722},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fconvert-excel-file-to-pdf-with-python\u002F","Convert an Excel file to PDF with Python",[114,725,727],{"id":726},"schedule-it","Schedule it",[10,729,730],{},"Cron on the host is the simplest scheduler, and it works because the container exits:",[122,732,734],{"className":305,"code":733,"language":307,"meta":127,"style":127},"30 6 * * 1-5 \u002Fusr\u002Fbin\u002Fdocker run --rm --env-file \u002Fetc\u002Freporting\u002Freport.env \\\n  -v \u002Fsrv\u002Freports\u002Fdata:\u002Fdata:ro -v \u002Fsrv\u002Freports\u002Fout:\u002Fout \\\n  sales-report:2026-08 >> \u002Fvar\u002Flog\u002Fsales-report.log 2>&1\n",[129,735,736,765,780],{"__ignoreMap":127},[159,737,738,740,743,746,748,751,754,756,758,761,763],{"class":58,"line":161},[159,739,67],{"class":314},[159,741,742],{"class":322}," 6",[159,744,745],{"class":322}," *",[159,747,745],{"class":322},[159,749,750],{"class":318}," 1-5",[159,752,753],{"class":318}," \u002Fusr\u002Fbin\u002Fdocker",[159,755,340],{"class":318},[159,757,343],{"class":322},[159,759,760],{"class":322}," --env-file",[159,762,659],{"class":318},[159,764,347],{"class":346},[159,766,767,769,772,775,778],{"class":58,"line":167},[159,768,352],{"class":322},[159,770,771],{"class":318}," \u002Fsrv\u002Freports\u002Fdata:\u002Fdata:ro",[159,773,774],{"class":322}," -v",[159,776,777],{"class":318}," \u002Fsrv\u002Freports\u002Fout:\u002Fout",[159,779,347],{"class":346},[159,781,782,784,788,791],{"class":58,"line":174},[159,783,392],{"class":318},[159,785,787],{"class":786},"s-kum"," >>",[159,789,790],{"class":318}," \u002Fvar\u002Flog\u002Fsales-report.log",[159,792,793],{"class":786}," 2>&1\n",[10,795,796,797,800,801,803,804,18],{},"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 ",[129,798,799],{},"PATH"," is minimal. See ",[14,802,17],{"href":16}," and, for a runner you do not maintain, ",[14,805,807],{"href":806},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions\u002F","Run a Python Excel report in GitHub Actions",[114,809,811],{"id":810},"make-the-container-report-its-own-health","Make the container report its own health",[10,813,814],{},"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:",[122,816,820],{"className":817,"code":818,"language":819,"meta":127,"style":127},"language-python shiki shiki-themes github-light github-dark-high-contrast","\"\"\"generate_report.py — exit codes a scheduler can act on.\"\"\"\nimport sys\n\nEXIT_OK, EXIT_NO_DATA, EXIT_FAILED = 0, 3, 1\n\ndef main() -> int:\n    rows = load_input()\n    if not rows:\n        print(\"no rows for the period — nothing to produce\", file=sys.stderr)\n        return EXIT_NO_DATA\n    try:\n        build_workbook(rows)\n    except Exception as exc:                     # noqa: BLE001\n        print(f\"report failed: {type(exc).__name__}: {exc}\", file=sys.stderr)\n        return EXIT_FAILED\n    print(\"report written\")\n    return EXIT_OK\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n","python",[129,821,822,827,835,839,871,875,893,904,915,937,945,952,957,975,1023,1030,1043,1051,1055,1071],{"__ignoreMap":127},[159,823,824],{"class":58,"line":161},[159,825,826],{"class":318},"\"\"\"generate_report.py — exit codes a scheduler can act on.\"\"\"\n",[159,828,829,832],{"class":58,"line":167},[159,830,831],{"class":786},"import",[159,833,834],{"class":358}," sys\n",[159,836,837],{"class":58,"line":174},[159,838,171],{"emptyLinePlaceholder":170},[159,840,841,844,847,850,852,855,858,861,863,866,868],{"class":58,"line":180},[159,842,843],{"class":322},"EXIT_OK",[159,845,846],{"class":358},", ",[159,848,849],{"class":322},"EXIT_NO_DATA",[159,851,846],{"class":358},[159,853,854],{"class":322},"EXIT_FAILED",[159,856,857],{"class":786}," =",[159,859,860],{"class":322}," 0",[159,862,846],{"class":358},[159,864,865],{"class":322},"3",[159,867,846],{"class":358},[159,869,870],{"class":322},"1\n",[159,872,873],{"class":58,"line":186},[159,874,171],{"emptyLinePlaceholder":170},[159,876,877,880,884,887,890],{"class":58,"line":192},[159,878,879],{"class":786},"def",[159,881,883],{"class":882},"s_Opv"," main",[159,885,886],{"class":358},"() -> ",[159,888,889],{"class":322},"int",[159,891,892],{"class":358},":\n",[159,894,895,898,901],{"class":58,"line":197},[159,896,897],{"class":358},"    rows ",[159,899,900],{"class":786},"=",[159,902,903],{"class":358}," load_input()\n",[159,905,906,909,912],{"class":58,"line":203},[159,907,908],{"class":786},"    if",[159,910,911],{"class":786}," not",[159,913,914],{"class":358}," rows:\n",[159,916,917,920,923,926,928,932,934],{"class":58,"line":209},[159,918,919],{"class":322},"        print",[159,921,922],{"class":358},"(",[159,924,925],{"class":318},"\"no rows for the period — nothing to produce\"",[159,927,846],{"class":358},[159,929,931],{"class":930},"sa561","file",[159,933,900],{"class":786},[159,935,936],{"class":358},"sys.stderr)\n",[159,938,939,942],{"class":58,"line":215},[159,940,941],{"class":786},"        return",[159,943,944],{"class":322}," EXIT_NO_DATA\n",[159,946,947,950],{"class":58,"line":220},[159,948,949],{"class":786},"    try",[159,951,892],{"class":358},[159,953,954],{"class":58,"line":226},[159,955,956],{"class":358},"        build_workbook(rows)\n",[159,958,959,962,965,968,971],{"class":58,"line":231},[159,960,961],{"class":786},"    except",[159,963,964],{"class":322}," Exception",[159,966,967],{"class":786}," as",[159,969,970],{"class":358}," exc:                     ",[159,972,974],{"class":973},"s-wDw","# noqa: BLE001\n",[159,976,977,979,981,984,987,990,993,996,999,1002,1005,1007,1010,1012,1015,1017,1019,1021],{"class":58,"line":237},[159,978,919],{"class":322},[159,980,922],{"class":358},[159,982,983],{"class":786},"f",[159,985,986],{"class":318},"\"report failed: ",[159,988,989],{"class":346},"{",[159,991,992],{"class":322},"type",[159,994,995],{"class":358},"(exc).",[159,997,998],{"class":322},"__name__",[159,1000,1001],{"class":346},"}",[159,1003,1004],{"class":318},": ",[159,1006,989],{"class":346},[159,1008,1009],{"class":358},"exc",[159,1011,1001],{"class":346},[159,1013,1014],{"class":318},"\"",[159,1016,846],{"class":358},[159,1018,931],{"class":930},[159,1020,900],{"class":786},[159,1022,936],{"class":358},[159,1024,1025,1027],{"class":58,"line":243},[159,1026,941],{"class":786},[159,1028,1029],{"class":322}," EXIT_FAILED\n",[159,1031,1032,1035,1037,1040],{"class":58,"line":248},[159,1033,1034],{"class":322},"    print",[159,1036,922],{"class":358},[159,1038,1039],{"class":318},"\"report written\"",[159,1041,1042],{"class":358},")\n",[159,1044,1045,1048],{"class":58,"line":254},[159,1046,1047],{"class":786},"    return",[159,1049,1050],{"class":322}," EXIT_OK\n",[159,1052,1053],{"class":58,"line":259},[159,1054,171],{"emptyLinePlaceholder":170},[159,1056,1057,1060,1063,1066,1069],{"class":58,"line":265},[159,1058,1059],{"class":786},"if",[159,1061,1062],{"class":322}," __name__",[159,1064,1065],{"class":786}," ==",[159,1067,1068],{"class":318}," \"__main__\"",[159,1070,892],{"class":358},[159,1072,1073,1076,1079],{"class":58,"line":271},[159,1074,1075],{"class":786},"    raise",[159,1077,1078],{"class":322}," SystemExit",[159,1080,1081],{"class":358},"(main())\n",[10,1083,1084],{},"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.",[20,1086,29,1091,29,1094,29,1097,29,1100,29,1103,29,1107,29,1111,29,1115,29,1119,29,1122,29,1127,29,1130,29,1134,29,1138,29,1143,29,1146],{"viewBox":1087,"role":23,"ariaLabelledBy":1088,"xmlns":27,"style":28},"0 0 760 202",[1089,1090],"dk3-t","dk3-d",[31,1092,1093],{"id":1089},"Three exit codes and what the scheduler does with each",[35,1095,1096],{"id":1090},"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.",[39,1098],{"x":41,"y":41,"width":42,"height":1099,"fill":44},"202",[46,1101,1102],{"x":60,"y":429,"style":430},"Not every non-success is a failure",[39,1104],{"x":49,"y":68,"width":1105,"height":1106,"rx":462,"fill":95,"stroke":63},"226","120",[46,1108,545],{"x":1109,"y":1110,"style":55},"141","80",[46,1112,1114],{"x":1109,"y":448,"style":1113},"font-size:11.5px;fill:var(--text,#172033);text-anchor:middle","workbook written",[46,1116,1118],{"x":1109,"y":435,"style":1117},"font-size:11.5px;fill:var(--teal-ink,#0b6157);text-anchor:middle","deliver it",[39,1120],{"x":1121,"y":68,"width":1105,"height":1106,"rx":462,"fill":491,"stroke":63},"268",[46,1123,1126],{"x":1124,"y":1110,"style":1125},"381","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","exit 3",[46,1128,1129],{"x":1124,"y":448,"style":1113},"no data this period",[46,1131,1133],{"x":1124,"y":435,"style":1132},"font-size:11.5px;fill:var(--gold-ink,#7a4e06);text-anchor:middle","log it, no alert",[39,1135],{"x":1136,"y":68,"width":1137,"height":1106,"rx":462,"fill":108,"stroke":63},"508","224",[46,1139,1142],{"x":1140,"y":1110,"style":1141},"620","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","exit 1",[46,1144,1145],{"x":1140,"y":448,"style":1113},"the run failed",[46,1147,1149],{"x":1140,"y":435,"style":1148},"font-size:11.5px;fill:var(--accent-ink,#be185d);text-anchor:middle","retry, then alert",[114,1151,1153],{"id":1152},"test-the-image-before-it-goes-near-a-schedule","Test the image before it goes near a schedule",[10,1155,1156],{},"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:",[122,1158,1160],{"className":305,"code":1159,"language":307,"meta":127,"style":127},"docker build -t sales-report:test .\ndocker run --rm --user \"$(id -u):$(id -g)\" \\\n  -v \"$PWD\u002Ftests\u002Ffixtures:\u002Fdata:ro\" -v \"$PWD\u002Ftmp:\u002Fout\" \\\n  sales-report:test --input \u002Fdata\u002Fsales.xlsx --output \u002Fout\u002Fsummary.xlsx\n\npython - \u003C\u003C'CHECK'\nimport pandas as pd\ndf = pd.read_excel(\"tmp\u002Fsummary.xlsx\", engine=\"openpyxl\")\nassert list(df.columns) == [\"region\", \"revenue\"], df.columns\nassert len(df) == 4, len(df)\nprint(\"container output looks right\")\nCHECK\n",[129,1161,1162,1175,1202,1224,1237,1241,1255,1260,1265,1270,1275,1280],{"__ignoreMap":127},[159,1163,1164,1166,1168,1170,1173],{"class":58,"line":161},[159,1165,315],{"class":314},[159,1167,319],{"class":318},[159,1169,323],{"class":322},[159,1171,1172],{"class":318}," sales-report:test",[159,1174,329],{"class":318},[159,1176,1177,1179,1181,1183,1186,1188,1190,1192,1194,1196,1198,1200],{"class":58,"line":167},[159,1178,315],{"class":314},[159,1180,340],{"class":318},[159,1182,343],{"class":322},[159,1184,1185],{"class":322}," --user",[159,1187,575],{"class":318},[159,1189,578],{"class":314},[159,1191,581],{"class":322},[159,1193,584],{"class":318},[159,1195,578],{"class":314},[159,1197,589],{"class":322},[159,1199,592],{"class":318},[159,1201,347],{"class":346},[159,1203,1204,1206,1208,1210,1213,1215,1217,1219,1222],{"class":58,"line":174},[159,1205,352],{"class":322},[159,1207,355],{"class":318},[159,1209,359],{"class":358},[159,1211,1212],{"class":318},"\u002Ftests\u002Ffixtures:\u002Fdata:ro\"",[159,1214,774],{"class":322},[159,1216,355],{"class":318},[159,1218,359],{"class":358},[159,1220,1221],{"class":318},"\u002Ftmp:\u002Fout\"",[159,1223,347],{"class":346},[159,1225,1226,1229,1231,1233,1235],{"class":58,"line":180},[159,1227,1228],{"class":318},"  sales-report:test",[159,1230,395],{"class":322},[159,1232,398],{"class":318},[159,1234,401],{"class":322},[159,1236,404],{"class":318},[159,1238,1239],{"class":58,"line":186},[159,1240,171],{"emptyLinePlaceholder":170},[159,1242,1243,1245,1248,1251],{"class":58,"line":192},[159,1244,819],{"class":314},[159,1246,1247],{"class":318}," -",[159,1249,1250],{"class":786}," \u003C\u003C",[159,1252,1254],{"class":1253},"sXKb-","'CHECK'\n",[159,1256,1257],{"class":58,"line":197},[159,1258,1259],{"class":318},"import pandas as pd\n",[159,1261,1262],{"class":58,"line":203},[159,1263,1264],{"class":318},"df = pd.read_excel(\"tmp\u002Fsummary.xlsx\", engine=\"openpyxl\")\n",[159,1266,1267],{"class":58,"line":209},[159,1268,1269],{"class":318},"assert list(df.columns) == [\"region\", \"revenue\"], df.columns\n",[159,1271,1272],{"class":58,"line":215},[159,1273,1274],{"class":318},"assert len(df) == 4, len(df)\n",[159,1276,1277],{"class":58,"line":220},[159,1278,1279],{"class":318},"print(\"container output looks right\")\n",[159,1281,1282],{"class":58,"line":226},[159,1283,1284],{"class":1253},"CHECK\n",[10,1286,1287],{},"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.",[114,1289,1291],{"id":1290},"common-pitfalls-and-gotchas","Common pitfalls and gotchas",[1293,1294,1295,1307,1319,1328,1337],"ul",{},[1296,1297,1298,1302,1303,1306],"li",{},[1299,1300,1301],"strong",{},"Root-owned output."," Run as a non-root user and pass ",[129,1304,1305],{},"--user"," for bind mounts.",[1296,1308,1309,1312,1313,1315,1316,1318],{},[1299,1310,1311],{},"UTC dates."," Install ",[129,1314,293],{}," and set ",[129,1317,297],{},", or a report dated at midnight lands on the wrong day.",[1296,1320,1321,1324,1325,1327],{},[1299,1322,1323],{},"Buffered logs."," Without ",[129,1326,285],{},", a crashed run appears to have logged nothing.",[1296,1329,1330,1336],{},[1299,1331,1332,1335],{},[129,1333,1334],{},"latest"," tags."," Tag images by date or version so a failed run can be reproduced exactly.",[1296,1338,1339,1342],{},[1299,1340,1341],{},"Secrets in layers."," Deleting a file in a later layer does not remove it from the image.",[114,1344,1346],{"id":1345},"performance-and-scale-notes","Performance and scale notes",[10,1348,1349,1350,1353,1354,18],{},"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 ",[129,1351,1352],{},"--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 ",[14,1355,1357],{"href":1356},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-large-excel-files-in-python\u002Fprocess-multiple-excel-files-in-parallel-with-python\u002F","Process multiple Excel files in parallel with Python",[114,1359,1361],{"id":1360},"conclusion","Conclusion",[10,1363,1364],{},"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.",[114,1366,1368],{"id":1367},"frequently-asked-questions","Frequently asked questions",[10,1370,1371,1374,1375,1378],{},[1299,1372,1373],{},"Do I need Excel or LibreOffice in the image?","\nNot for pandas, openpyxl or xlsxwriter — they write ",[129,1376,1377],{},".xlsx"," in pure Python. You only need LibreOffice if the job converts to PDF or must recalculate formulas, and it adds several hundred megabytes.",[10,1380,1381,1384],{},[1299,1382,1383],{},"Why does my container write files nobody can read?","\nThe 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.",[10,1386,1387,1390,1391,1393,1394,1396],{},[1299,1388,1389],{},"Why are the dates in my report a day out?","\nThe container's clock is UTC unless told otherwise. Set the ",[129,1392,297],{}," environment variable, and install ",[129,1395,293],{}," if the base image lacks it.",[10,1398,1399,1402],{},[1299,1400,1401],{},"Should the container run continuously or once per report?","\nOnce 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.",[10,1404,1405,1408],{},[1299,1406,1407],{},"How do I get credentials into the container?","\nEnvironment variables from the orchestrator, or a mounted secrets file. Never bake them into the image — anyone who can pull it can read them.",[114,1410,1412],{"id":1411},"related","Related",[1293,1414,1415,1424,1429,1436,1443],{},[1296,1416,1417,1420,1421,1423],{},[1299,1418,1419],{},"Up:"," ",[14,1422,17],{"href":16}," — the scheduler that starts this container.",[1296,1425,1426,1428],{},[14,1427,807],{"href":806}," — the same job on a runner you do not maintain.",[1296,1430,1431,1435],{},[14,1432,1434],{"href":1433},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fpackage-a-python-excel-script-as-an-exe-with-pyinstaller\u002F","Package a Python Excel script as an exe with PyInstaller"," — the desktop alternative to a container.",[1296,1437,1438,1442],{},[14,1439,1441],{"href":1440},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Flog-python-excel-script-output-to-a-file\u002F","Log Python Excel script output to a file"," — what to do with the container's stdout.",[1296,1444,1445,1447],{},[14,1446,691],{"href":690}," — configuration that belongs outside the image.",[1449,1450,1451],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sXKb-, html code.shiki .sXKb-{--shiki-default:#032F62;--shiki-dark:#F0F3F6}",{"title":127,"searchDepth":167,"depth":167,"links":1453},[1454,1455,1456,1457,1458,1459,1460,1461,1462,1463,1464,1465,1466,1467,1468],{"id":116,"depth":167,"text":117},{"id":133,"depth":167,"text":134},{"id":149,"depth":167,"text":150},{"id":301,"depth":167,"text":302},{"id":548,"depth":167,"text":549},{"id":624,"depth":167,"text":625},{"id":694,"depth":167,"text":695},{"id":726,"depth":167,"text":727},{"id":810,"depth":167,"text":811},{"id":1152,"depth":167,"text":1153},{"id":1290,"depth":167,"text":1291},{"id":1345,"depth":167,"text":1346},{"id":1360,"depth":167,"text":1361},{"id":1367,"depth":167,"text":1368},{"id":1411,"depth":167,"text":1412},"2026-08-27","Containerise a reporting job: a small Dockerfile, pinned dependencies, timezone and locale, mounted volumes for input and output, secrets, and scheduling the container.","md",[1473,1475,1477,1479,1481],{"q":1373,"a":1474},"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.",{"q":1383,"a":1476},"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.",{"q":1389,"a":1478},"The container's clock is UTC unless told otherwise. Set the TZ environment variable, and install tzdata if the base image lacks it.",{"q":1401,"a":1480},"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.",{"q":1407,"a":1482},"Environment variables from the orchestrator, or a mounted secrets file. Never bake them into the image — anyone who can pull it can read them.",{},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-docker",{"title":5,"description":1486},"Package an Excel reporting script as a container: slim base image, pinned requirements, non-root user, timezone, volume mounts, environment secrets and scheduled runs.","run-a-python-excel-report-in-docker","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-docker\u002Findex","how-to","FXx6fe_4QfdkMfxRIR5BEfxVlqRsZYgkFlqq8s9I1VA",[1492,1496],{"title":1493,"path":1494,"stem":1495,"children":-1},"Orchestrate Excel Reports with Apache Airflow","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-apache-airflow","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-apache-airflow\u002Findex",{"title":1497,"path":1498,"stem":1499,"children":-1},"Run a Python Excel Report in GitHub Actions","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions\u002Findex",1788710154301]