[{"data":1,"prerenderedAt":2806},["ShallowReactive",2],{"page-\u002Fscaling-and-operating-production-python-apis\u002Fcontainerizing-python-apis-with-docker\u002F":3,"faq-schema-\u002Fscaling-and-operating-production-python-apis\u002Fcontainerizing-python-apis-with-docker\u002F":2785},{"id":4,"title":5,"body":6,"description":2775,"extension":2776,"meta":2777,"navigation":482,"path":2781,"seo":2782,"stem":2783,"__hash__":2784},"content\u002Fscaling-and-operating-production-python-apis\u002Fcontainerizing-python-apis-with-docker\u002Findex.md","Containerizing Python APIs with Docker for Reproducible Production Deploys",{"type":7,"value":8,"toc":2756},"minimark",[9,13,28,36,159,162,167,170,246,249,334,340,342,346,367,443,453,455,459,466,867,891,896,911,992,996,1023,1025,1029,1040,1043,1178,1189,1206,1208,1212,1227,1312,1326,1347,1349,1353,1363,1700,1732,1845,1847,1851,1888,1908,1978,1980,1984,1991,2055,2066,2068,2072,2213,2219,2221,2225,2321,2323,2327,2330,2506,2533,2535,2539,2551,2604,2620,2622,2626,2635,2651,2664,2676,2696,2698,2702,2707,2733,2738,2752],[10,11,5],"h1",{"id":12},"containerizing-python-apis-with-docker-for-reproducible-production-deploys",[14,15,16,17,22,23,27],"p",{},"\"Works on my machine\" is the most expensive sentence in API operations. When your FastAPI service depends on a specific Python patch release, a system library that happens to be on your laptop, and three environment variables you set months ago and forgot, every deploy becomes a gamble. A Docker image freezes the runtime, the dependencies, and the entry command into one artifact you build once and run identically on a teammate's machine, in CI, and on production. That reproducibility is the whole point: the image that passed your tests is byte-for-byte the image serving customers. Part of the ",[18,19,21],"a",{"href":20},"\u002Fscaling-and-operating-production-python-apis\u002F","Scaling and Operating Production Python APIs"," guide, this page walks through a production-grade multi-stage Dockerfile, a ",[24,25,26],"code",{},"docker-compose"," stack with Redis and Postgres, and the operational details — non-root users, signal handling, healthchecks, multi-arch builds — that separate a demo container from one you can charge customers to depend on.",[14,29,30,31,35],{},"Containers are not a nicety once you sell API access. The moment a paying customer's integration breaks because staging ran Python 3.11.4 and production ran 3.11.9 with a different OpenSSL, you have burned trust that costs far more than the hours you saved skipping this setup. Get the image right once and every future deploy — including ",[18,32,34],{"href":33},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fzero-downtime-deploys-for-python-apis\u002F","zero-downtime deploys"," — inherits the same guarantees.",[37,38,46,47,46,51,46,55,46,74,46,81,46,90,46,98,46,103,46,110,46,113,46,117,46,120,46,124,46,127,46,131,46,134,46,138,46,142,46,146,46,149,46,155],"svg",{"viewBox":39,"role":40,"ariaLabelledBy":41,"xmlns":44,"style":45},"0 0 720 240","img",[42,43],"dkr-flow-t","dkr-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[48,49,50],"title",{"id":42},"Multi-stage Docker build and deploy flow",[52,53,54],"desc",{"id":43},"A builder stage compiles dependencies, a slim runtime stage copies only what runs, the image is pushed to a registry, and the platform pulls and deploys it.",[56,57,58,59,46],"defs",{},"\n    ",[60,61,68,69,58],"marker",{"id":62,"viewBox":63,"refX":64,"refY":65,"markerWidth":66,"markerHeight":66,"orient":67},"dkr-flow-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[70,71],"path",{"d":72,"fill":73},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[75,76],"rect",{"x":77,"y":77,"width":78,"height":79,"fill":80},"0","720","240","var(--c-surface)",[75,82],{"x":83,"y":84,"width":85,"height":86,"rx":87,"fill":80,"stroke":88,"style":89},"16","90","150","64","10","var(--c-yellow)","stroke-width:2;",[91,92,97],"text",{"x":93,"y":94,"fill":95,"style":96},"91","116","var(--c-text)","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Builder stage",[91,99,102],{"x":93,"y":100,"fill":73,"style":101},"134","text-anchor:middle;font-size:11;font-family:var(--font-sans);","compile deps",[104,105],"line",{"x1":106,"y1":107,"x2":108,"y2":107,"stroke":73,"style":109},"166","122","206","stroke-width:2;marker-end:url(#dkr-flow-arrow);",[75,111],{"x":108,"y":84,"width":85,"height":86,"rx":87,"fill":80,"stroke":112,"style":89},"var(--c-blue)",[91,114,116],{"x":115,"y":94,"fill":95,"style":96},"281","Runtime stage",[91,118,119],{"x":115,"y":100,"fill":73,"style":101},"slim, non-root",[104,121],{"x1":122,"y1":107,"x2":123,"y2":107,"stroke":73,"style":109},"356","396",[75,125],{"x":123,"y":84,"width":85,"height":86,"rx":87,"fill":80,"stroke":126,"style":89},"var(--c-teal)",[91,128,130],{"x":129,"y":94,"fill":95,"style":96},"471","Registry",[91,132,133],{"x":129,"y":100,"fill":73,"style":101},"tagged image",[104,135],{"x1":136,"y1":107,"x2":137,"y2":107,"stroke":73,"style":109},"546","586",[75,139],{"x":137,"y":84,"width":140,"height":86,"rx":87,"fill":80,"stroke":141,"style":89},"118","var(--c-coral)",[91,143,145],{"x":144,"y":94,"fill":95,"style":96},"645","Deploy",[91,147,148],{"x":144,"y":100,"fill":73,"style":101},"pull & run",[91,150,154],{"x":151,"y":152,"fill":95,"style":153},"360","40","text-anchor:middle;font-size:14;font-family:var(--font-sans);","One artifact, built once, run identically everywhere",[91,156,158],{"x":151,"y":157,"fill":73,"style":101},"200","Compilers and caches stay in the builder — only the runtime image ships",[160,161],"hr",{},[163,164,166],"h2",{"id":165},"prerequisites","Prerequisites",[14,168,169],{},"Before you build anything, line up the moving parts:",[171,172,173,196,211,236],"ul",{},[174,175,176,180,181,184,185,188,189,192,193,195],"li",{},[177,178,179],"strong",{},"Docker Engine 24+"," (or Docker Desktop). Confirm with ",[24,182,183],{},"docker --version"," and ",[24,186,187],{},"docker compose version",". The ",[24,190,191],{},"docker compose"," subcommand replaced the standalone ",[24,194,26],{}," binary; if you are still typing the hyphenated form, upgrade.",[174,197,198,201,202,205,206,210],{},[177,199,200],{},"A FastAPI app"," with an ASGI entrypoint, e.g. ",[24,203,204],{},"app.main:app",". If you don't have one yet, start with ",[18,207,209],{"href":208},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002F","Setting Up FastAPI",".",[174,212,213,216,217,220,221,224,225,231,232,235],{},[177,214,215],{},"A pinned dependency file"," — either ",[24,218,219],{},"requirements.txt"," or a ",[24,222,223],{},"pyproject.toml"," + lockfile. We'll use ",[18,226,230],{"href":227,"rel":228},"https:\u002F\u002Fgithub.com\u002Fastral-sh\u002Fuv",[229],"nofollow","uv"," for fast, reproducible installs, but ",[24,233,234],{},"pip"," works identically.",[174,237,238,241,242,210],{},[177,239,240],{},"Environment variables"," for configuration. Every secret (database URL, API keys) is injected at runtime — never baked into the image. We follow the same env-first discipline used throughout ",[18,243,245],{"href":244},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002F","handling API authentication in Python",[14,247,248],{},"Project layout assumed:",[250,251,256],"pre",{"className":252,"code":253,"language":254,"meta":255,"style":255},"language-bash shiki shiki-themes github-light github-dark",".\n├── app\u002F\n│   └── main.py          # exposes `app = FastAPI()`\n├── pyproject.toml\n├── uv.lock\n├── Dockerfile\n├── docker-compose.yml\n└── .dockerignore\n","bash","",[24,257,258,266,277,293,301,309,317,325],{"__ignoreMap":255},[259,260,262],"span",{"class":104,"line":261},1,[259,263,265],{"class":264},"sj4cs",".\n",[259,267,269,273],{"class":104,"line":268},2,[259,270,272],{"class":271},"sScJk","├──",[259,274,276],{"class":275},"sZZnC"," app\u002F\n",[259,278,280,283,286,289],{"class":104,"line":279},3,[259,281,282],{"class":271},"│",[259,284,285],{"class":275},"   └──",[259,287,288],{"class":275}," main.py",[259,290,292],{"class":291},"sJ8bj","          # exposes `app = FastAPI()`\n",[259,294,296,298],{"class":104,"line":295},4,[259,297,272],{"class":271},[259,299,300],{"class":275}," pyproject.toml\n",[259,302,304,306],{"class":104,"line":303},5,[259,305,272],{"class":271},[259,307,308],{"class":275}," uv.lock\n",[259,310,312,314],{"class":104,"line":311},6,[259,313,272],{"class":271},[259,315,316],{"class":275}," Dockerfile\n",[259,318,320,322],{"class":104,"line":319},7,[259,321,272],{"class":271},[259,323,324],{"class":275}," docker-compose.yml\n",[259,326,328,331],{"class":104,"line":327},8,[259,329,330],{"class":271},"└──",[259,332,333],{"class":275}," .dockerignore\n",[14,335,336,337,210],{},"The rest of this guide follows a deliberate order: ignore file first, then the image, then the process model, then secrets, then the full stack. Each step assumes the one before it, and skipping ahead is how you end up with a 1.2GB image that leaks your ",[24,338,339],{},".env",[160,341],{},[163,343,345],{"id":344},"step-1-write-a-dockerignore-first","Step 1: Write a .dockerignore first",[14,347,348,349,352,353,356,357,360,361,364,365,210],{},"The build context is everything Docker sends to the daemon before the first instruction runs. A missing ",[24,350,351],{},".dockerignore"," means your ",[24,354,355],{},".git"," history, local ",[24,358,359],{},".venv",", and ",[24,362,363],{},"__pycache__"," get shipped into the build — bloating the context, slowing every build, and risking secret leakage from a stray ",[24,366,339],{},[250,368,370],{"className":252,"code":369,"language":254,"meta":255,"style":255},"# .dockerignore\n.git\n.venv\n__pycache__\n*.pyc\n.env\n.env.*\n.pytest_cache\n.mypy_cache\nDockerfile\ndocker-compose.yml\n*.md\n",[24,371,372,377,382,387,392,402,407,412,417,423,429,435],{"__ignoreMap":255},[259,373,374],{"class":104,"line":261},[259,375,376],{"class":291},"# .dockerignore\n",[259,378,379],{"class":104,"line":268},[259,380,381],{"class":271},".git\n",[259,383,384],{"class":104,"line":279},[259,385,386],{"class":271},".venv\n",[259,388,389],{"class":104,"line":295},[259,390,391],{"class":271},"__pycache__\n",[259,393,394,398],{"class":104,"line":303},[259,395,397],{"class":396},"szBVR","*",[259,399,401],{"class":400},"sVt8B",".pyc\n",[259,403,404],{"class":104,"line":311},[259,405,406],{"class":271},".env\n",[259,408,409],{"class":104,"line":319},[259,410,411],{"class":271},".env.*\n",[259,413,414],{"class":104,"line":327},[259,415,416],{"class":271},".pytest_cache\n",[259,418,420],{"class":104,"line":419},9,[259,421,422],{"class":271},".mypy_cache\n",[259,424,426],{"class":104,"line":425},10,[259,427,428],{"class":271},"Dockerfile\n",[259,430,432],{"class":104,"line":431},11,[259,433,434],{"class":271},"docker-compose.yml\n",[259,436,438,440],{"class":104,"line":437},12,[259,439,397],{"class":396},[259,441,442],{"class":400},".md\n",[14,444,445,446,448,449,452],{},"This single file is the cheapest performance and security win in the whole process. It matters more than it looks. On a project with a modest ",[24,447,355],{}," directory and a local virtual environment, the build context can drop from several hundred megabytes to a few hundred kilobytes — and a smaller context means the ",[24,450,451],{},"COPY . ."," instruction in a naive Dockerfile does not silently drag your development junk into a layer. Write it before the Dockerfile, not after. If you already built an image without one, assume the context leaked and rebuild clean.",[160,454],{},[163,456,458],{"id":457},"step-2-build-a-multi-stage-dockerfile","Step 2: Build a multi-stage Dockerfile",[14,460,461,462,210],{},"A multi-stage build uses a fat \"builder\" image to compile and install dependencies, then copies only the resulting virtual environment into a clean slim runtime. The compilers, build headers, and caches stay behind in a stage that never ships. This is the core technique behind ",[18,463,465],{"href":464},"\u002Fscaling-and-operating-production-python-apis\u002Fcontainerizing-python-apis-with-docker\u002Foptimizing-python-docker-image-size\u002F","optimizing Python Docker image size",[250,467,471],{"className":468,"code":469,"language":470,"meta":255,"style":255},"language-dockerfile shiki shiki-themes github-light github-dark","# syntax=docker\u002Fdockerfile:1\n\n############ Builder stage ############\nFROM python:3.11-slim AS builder\n\n# uv: a fast, reproducible installer. Copy the static binary in.\nCOPY --from=ghcr.io\u002Fastral-sh\u002Fuv:latest \u002Fuv \u002Fusr\u002Flocal\u002Fbin\u002Fuv\n\nENV UV_COMPILE_BYTECODE=1 \\\n    UV_LINK_MODE=copy \\\n    UV_PROJECT_ENVIRONMENT=\u002Fopt\u002Fvenv\n\nWORKDIR \u002Fbuild\n\n# Install deps in their own layer so code changes don't bust the cache.\nCOPY pyproject.toml uv.lock .\u002F\nRUN --mount=type=cache,target=\u002Froot\u002F.cache\u002Fuv \\\n    uv sync --frozen --no-install-project --no-dev\n\n############ Runtime stage ############\nFROM python:3.11-slim AS runtime\n\n# Create an unprivileged user. Running as root is a needless blast radius.\nRUN groupadd --system app && useradd --system --gid app --home \u002Fapp app\n\nENV PATH=\"\u002Fopt\u002Fvenv\u002Fbin:$PATH\" \\\n    PYTHONUNBUFFERED=1 \\\n    PYTHONDONTWRITEBYTECODE=1\n\n# Copy the prebuilt virtual environment from the builder.\nCOPY --from=builder \u002Fopt\u002Fvenv \u002Fopt\u002Fvenv\n\nWORKDIR \u002Fapp\nCOPY --chown=app:app .\u002Fapp .\u002Fapp\n\nUSER app\nEXPOSE 8000\n\n# Default config; override per environment at runtime.\nENV PORT=8000 \\\n    WEB_CONCURRENCY=4\n\n# Healthcheck hits a lightweight endpoint so orchestrators can detect a hung worker.\nHEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\\n    CMD python -c \"import os,urllib.request; urllib.request.urlopen(f'http:\u002F\u002F127.0.0.1:{os.getenv(\\\"PORT\\\",\\\"8000\\\")}\u002Fhealth').read()\" || exit 1\n\n# Exec form (no shell) makes gunicorn PID 1 so it receives SIGTERM directly.\nCMD [\"sh\", \"-c\", \"exec gunicorn app.main:app \\\n    --worker-class uvicorn.workers.UvicornWorker \\\n    --workers ${WEB_CONCURRENCY:-4} \\\n    --bind 0.0.0.0:${PORT:-8000} \\\n    --timeout 60 --graceful-timeout 30\"]\n","dockerfile",[24,472,473,478,484,489,503,507,512,520,524,532,537,542,546,555,560,566,574,583,589,594,600,612,617,623,631,636,650,656,662,667,673,681,686,694,702,707,716,725,730,736,744,750,755,761,770,806,811,817,840,846,852,858],{"__ignoreMap":255},[259,474,475],{"class":104,"line":261},[259,476,477],{"class":291},"# syntax=docker\u002Fdockerfile:1\n",[259,479,480],{"class":104,"line":268},[259,481,483],{"emptyLinePlaceholder":482},true,"\n",[259,485,486],{"class":104,"line":279},[259,487,488],{"class":291},"############ Builder stage ############\n",[259,490,491,494,497,500],{"class":104,"line":295},[259,492,493],{"class":396},"FROM",[259,495,496],{"class":400}," python:3.11-slim ",[259,498,499],{"class":396},"AS",[259,501,502],{"class":400}," builder\n",[259,504,505],{"class":104,"line":303},[259,506,483],{"emptyLinePlaceholder":482},[259,508,509],{"class":104,"line":311},[259,510,511],{"class":291},"# uv: a fast, reproducible installer. Copy the static binary in.\n",[259,513,514,517],{"class":104,"line":319},[259,515,516],{"class":396},"COPY",[259,518,519],{"class":400}," --from=ghcr.io\u002Fastral-sh\u002Fuv:latest \u002Fuv \u002Fusr\u002Flocal\u002Fbin\u002Fuv\n",[259,521,522],{"class":104,"line":327},[259,523,483],{"emptyLinePlaceholder":482},[259,525,526,529],{"class":104,"line":419},[259,527,528],{"class":396},"ENV",[259,530,531],{"class":400}," UV_COMPILE_BYTECODE=1 \\\n",[259,533,534],{"class":104,"line":425},[259,535,536],{"class":400},"    UV_LINK_MODE=copy \\\n",[259,538,539],{"class":104,"line":431},[259,540,541],{"class":400},"    UV_PROJECT_ENVIRONMENT=\u002Fopt\u002Fvenv\n",[259,543,544],{"class":104,"line":437},[259,545,483],{"emptyLinePlaceholder":482},[259,547,549,552],{"class":104,"line":548},13,[259,550,551],{"class":396},"WORKDIR",[259,553,554],{"class":400}," \u002Fbuild\n",[259,556,558],{"class":104,"line":557},14,[259,559,483],{"emptyLinePlaceholder":482},[259,561,563],{"class":104,"line":562},15,[259,564,565],{"class":291},"# Install deps in their own layer so code changes don't bust the cache.\n",[259,567,569,571],{"class":104,"line":568},16,[259,570,516],{"class":396},[259,572,573],{"class":400}," pyproject.toml uv.lock .\u002F\n",[259,575,577,580],{"class":104,"line":576},17,[259,578,579],{"class":396},"RUN",[259,581,582],{"class":400}," --mount=type=cache,target=\u002Froot\u002F.cache\u002Fuv \\\n",[259,584,586],{"class":104,"line":585},18,[259,587,588],{"class":400},"    uv sync --frozen --no-install-project --no-dev\n",[259,590,592],{"class":104,"line":591},19,[259,593,483],{"emptyLinePlaceholder":482},[259,595,597],{"class":104,"line":596},20,[259,598,599],{"class":291},"############ Runtime stage ############\n",[259,601,603,605,607,609],{"class":104,"line":602},21,[259,604,493],{"class":396},[259,606,496],{"class":400},[259,608,499],{"class":396},[259,610,611],{"class":400}," runtime\n",[259,613,615],{"class":104,"line":614},22,[259,616,483],{"emptyLinePlaceholder":482},[259,618,620],{"class":104,"line":619},23,[259,621,622],{"class":291},"# Create an unprivileged user. Running as root is a needless blast radius.\n",[259,624,626,628],{"class":104,"line":625},24,[259,627,579],{"class":396},[259,629,630],{"class":400}," groupadd --system app && useradd --system --gid app --home \u002Fapp app\n",[259,632,634],{"class":104,"line":633},25,[259,635,483],{"emptyLinePlaceholder":482},[259,637,639,641,644,647],{"class":104,"line":638},26,[259,640,528],{"class":396},[259,642,643],{"class":400}," PATH=",[259,645,646],{"class":275},"\"\u002Fopt\u002Fvenv\u002Fbin:$PATH\"",[259,648,649],{"class":400}," \\\n",[259,651,653],{"class":104,"line":652},27,[259,654,655],{"class":400},"    PYTHONUNBUFFERED=1 \\\n",[259,657,659],{"class":104,"line":658},28,[259,660,661],{"class":400},"    PYTHONDONTWRITEBYTECODE=1\n",[259,663,665],{"class":104,"line":664},29,[259,666,483],{"emptyLinePlaceholder":482},[259,668,670],{"class":104,"line":669},30,[259,671,672],{"class":291},"# Copy the prebuilt virtual environment from the builder.\n",[259,674,676,678],{"class":104,"line":675},31,[259,677,516],{"class":396},[259,679,680],{"class":400}," --from=builder \u002Fopt\u002Fvenv \u002Fopt\u002Fvenv\n",[259,682,684],{"class":104,"line":683},32,[259,685,483],{"emptyLinePlaceholder":482},[259,687,689,691],{"class":104,"line":688},33,[259,690,551],{"class":396},[259,692,693],{"class":400}," \u002Fapp\n",[259,695,697,699],{"class":104,"line":696},34,[259,698,516],{"class":396},[259,700,701],{"class":400}," --chown=app:app .\u002Fapp .\u002Fapp\n",[259,703,705],{"class":104,"line":704},35,[259,706,483],{"emptyLinePlaceholder":482},[259,708,710,713],{"class":104,"line":709},36,[259,711,712],{"class":396},"USER",[259,714,715],{"class":400}," app\n",[259,717,719,722],{"class":104,"line":718},37,[259,720,721],{"class":396},"EXPOSE",[259,723,724],{"class":400}," 8000\n",[259,726,728],{"class":104,"line":727},38,[259,729,483],{"emptyLinePlaceholder":482},[259,731,733],{"class":104,"line":732},39,[259,734,735],{"class":291},"# Default config; override per environment at runtime.\n",[259,737,739,741],{"class":104,"line":738},40,[259,740,528],{"class":396},[259,742,743],{"class":400}," PORT=8000 \\\n",[259,745,747],{"class":104,"line":746},41,[259,748,749],{"class":400},"    WEB_CONCURRENCY=4\n",[259,751,753],{"class":104,"line":752},42,[259,754,483],{"emptyLinePlaceholder":482},[259,756,758],{"class":104,"line":757},43,[259,759,760],{"class":291},"# Healthcheck hits a lightweight endpoint so orchestrators can detect a hung worker.\n",[259,762,764,767],{"class":104,"line":763},44,[259,765,766],{"class":396},"HEALTHCHECK",[259,768,769],{"class":400}," --interval=30s --timeout=3s --start-period=10s --retries=3 \\\n",[259,771,773,776,779,782,785,788,790,793,795,798,800,803],{"class":104,"line":772},45,[259,774,775],{"class":396},"    CMD",[259,777,778],{"class":400}," python -c ",[259,780,781],{"class":275},"\"import os,urllib.request; urllib.request.urlopen(f'http:\u002F\u002F127.0.0.1:{os.getenv(",[259,783,784],{"class":264},"\\\"",[259,786,787],{"class":275},"PORT",[259,789,784],{"class":264},[259,791,792],{"class":275},",",[259,794,784],{"class":264},[259,796,797],{"class":275},"8000",[259,799,784],{"class":264},[259,801,802],{"class":275},")}\u002Fhealth').read()\"",[259,804,805],{"class":400}," || exit 1\n",[259,807,809],{"class":104,"line":808},46,[259,810,483],{"emptyLinePlaceholder":482},[259,812,814],{"class":104,"line":813},47,[259,815,816],{"class":291},"# Exec form (no shell) makes gunicorn PID 1 so it receives SIGTERM directly.\n",[259,818,820,823,826,829,832,835,837],{"class":104,"line":819},48,[259,821,822],{"class":396},"CMD",[259,824,825],{"class":400}," [",[259,827,828],{"class":275},"\"sh\"",[259,830,831],{"class":400},", ",[259,833,834],{"class":275},"\"-c\"",[259,836,831],{"class":400},[259,838,839],{"class":275},"\"exec gunicorn app.main:app \\\n",[259,841,843],{"class":104,"line":842},49,[259,844,845],{"class":275},"    --worker-class uvicorn.workers.UvicornWorker \\\n",[259,847,849],{"class":104,"line":848},50,[259,850,851],{"class":275},"    --workers ${WEB_CONCURRENCY:-4} \\\n",[259,853,855],{"class":104,"line":854},51,[259,856,857],{"class":275},"    --bind 0.0.0.0:${PORT:-8000} \\\n",[259,859,861,864],{"class":104,"line":860},52,[259,862,863],{"class":275},"    --timeout 60 --graceful-timeout 30\"",[259,865,866],{"class":400},"]\n",[14,868,869,870,873,874,877,878,882,883,886,887,890],{},"A few choices worth calling out. The dependency layer (",[24,871,872],{},"COPY pyproject.toml uv.lock"," then ",[24,875,876],{},"uv sync",") sits ",[879,880,881],"em",{},"before"," the application code copy, so editing a route doesn't reinstall packages. The ",[24,884,885],{},"--mount=type=cache"," line keeps uv's download cache warm across builds without baking it into a layer. The runtime stage never sees a compiler. And the process runs as ",[24,888,889],{},"app",", not root.",[892,893,895],"h3",{"id":894},"why-layer-ordering-is-the-difference-between-a-3-second-and-a-3-minute-rebuild","Why layer ordering is the difference between a 3-second and a 3-minute rebuild",[14,897,898,899,901,902,184,904,907,908,910],{},"Docker caches layers top to bottom and invalidates every layer from the first change downward. If you ",[24,900,451],{}," before installing dependencies, then every one-line code change re-downloads and reinstalls your entire dependency tree — a minute or more on each CI run, and you pay for those build minutes. By copying only ",[24,903,223],{},[24,905,906],{},"uv.lock"," first, the expensive ",[24,909,876],{}," layer is reused whenever your dependencies are unchanged, which is the vast majority of commits. This ordering discipline is worth real money at CI scale: a team pushing 40 commits a day that turns three-minute builds into ten-second cache hits reclaims hours of build-minute billing every week.",[37,912,46,917,46,920,46,923,46,926,46,931,46,935,46,941,46,946,46,949,46,953,46,955,46,959,46,963,46,967,46,970,46,972,46,974,46,977,46,979,46,982,46,985,46,988],{"viewBox":913,"role":40,"ariaLabelledBy":914,"xmlns":44,"style":45},"0 0 720 300",[915,916],"dkr-cache-t","dkr-cache-d",[48,918,919],{"id":915},"Layer cache behaviour with good versus bad instruction ordering",[52,921,922],{"id":916},"Copying dependency files before source code keeps the expensive install layer cached on a code change; copying everything first invalidates the install layer every time.",[75,924],{"x":77,"y":77,"width":78,"height":925,"fill":80},"300",[91,927,930],{"x":928,"y":929,"fill":95,"style":153},"180","30","Deps copied first (good)",[91,932,934],{"x":933,"y":929,"fill":95,"style":153},"540","Everything copied first (bad)",[75,936],{"x":152,"y":937,"width":938,"height":152,"rx":939,"fill":940,"stroke":126,"style":89},"50","280","6","var(--c-surface-alt)",[91,942,945],{"x":928,"y":943,"fill":95,"style":944},"75","text-anchor:middle;font-size:12;font-family:var(--font-sans);","COPY lockfiles — CACHED",[75,947],{"x":152,"y":948,"width":938,"height":152,"rx":939,"fill":940,"stroke":126,"style":89},"100",[91,950,952],{"x":928,"y":951,"fill":95,"style":944},"125","uv sync (slow) — CACHED",[75,954],{"x":152,"y":85,"width":938,"height":152,"rx":939,"fill":940,"stroke":141,"style":89},[91,956,958],{"x":928,"y":957,"fill":95,"style":944},"175","COPY source — REBUILT",[91,960,962],{"x":928,"y":961,"fill":73,"style":944},"222","Only the cheap layer reruns",[91,964,966],{"x":928,"y":965,"fill":126,"style":96},"242","~10 seconds",[75,968],{"x":969,"y":937,"width":938,"height":152,"rx":939,"fill":940,"stroke":141,"style":89},"400",[91,971,958],{"x":933,"y":943,"fill":95,"style":944},[75,973],{"x":969,"y":948,"width":938,"height":152,"rx":939,"fill":940,"stroke":141,"style":89},[91,975,976],{"x":933,"y":951,"fill":95,"style":944},"uv sync (slow) — REBUILT",[75,978],{"x":969,"y":85,"width":938,"height":152,"rx":939,"fill":940,"stroke":141,"style":89},[91,980,981],{"x":933,"y":957,"fill":95,"style":944},"install project — REBUILT",[91,983,984],{"x":933,"y":961,"fill":73,"style":944},"Every deploy reinstalls everything",[91,986,987],{"x":933,"y":965,"fill":141,"style":96},"~3 minutes",[91,989,991],{"x":151,"y":990,"fill":73,"style":101},"282","A single edited route triggers the shaded layers to rebuild",[892,993,995],{"id":994},"choosing-a-base-image-slim-alpine-or-distroless","Choosing a base image: slim, alpine, or distroless",[14,997,998,999,1002,1003,1006,1007,1010,1011,1014,1015,1018,1019,210],{},"Reach for ",[24,1000,1001],{},"python:3.11-slim"," by default. It is Debian-based, so wheels install cleanly and you rarely hit a missing shared library. ",[24,1004,1005],{},"python:3.11"," (the full image) drags in build toolchains and docs you never run in production — skip it for the runtime stage. Alpine (",[24,1008,1009],{},"python:3.11-alpine",") looks tempting because it is tiny, but it uses musl instead of glibc, which means many Python packages have no prebuilt wheel and must compile from source, blowing up your build time and occasionally breaking at runtime in subtle ways. Distroless images (",[24,1012,1013],{},"gcr.io\u002Fdistroless\u002Fpython3",") strip the shell and package manager entirely for the smallest attack surface, but that same absence makes debugging a live container painful because you cannot ",[24,1016,1017],{},"docker exec"," a shell into it. For most builders shipping a commercial API, slim is the right trade of size, compatibility, and debuggability. Graduate to distroless only once your image is stable and you have external logging in place — which pairs naturally with ",[18,1020,1022],{"href":1021},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002F","monitoring and logging Python APIs",[160,1024],{},[163,1026,1028],{"id":1027},"step-3-run-gunicorn-with-uvicorn-workers-from-env","Step 3: Run gunicorn with uvicorn workers from env",[14,1030,1031,1032,1035,1036,210],{},"FastAPI is ASGI, so the production server is gunicorn managing ",[24,1033,1034],{},"UvicornWorker"," processes. The worker count is the single biggest performance lever, and it belongs in an environment variable — not the Dockerfile — so the same image can run with 2 workers on a small box and 8 on a large one. If you want the full reasoning on worker math, see ",[18,1037,1039],{"href":1038},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002Fuvicorn-vs-gunicorn-worker-configuration\u002F","Uvicorn vs Gunicorn worker configuration",[14,1041,1042],{},"Your app should read its own config the same env-first way:",[250,1044,1048],{"className":1045,"code":1046,"language":1047,"meta":255,"style":255},"language-python shiki shiki-themes github-light github-dark","# app\u002Fmain.py\nimport os\nfrom fastapi import FastAPI\n\napp = FastAPI(title=os.getenv(\"APP_NAME\", \"api\"))\n\n@app.get(\"\u002Fhealth\")\nasync def health():\n    return {\"status\": \"ok\", \"version\": os.getenv(\"APP_VERSION\", \"dev\")}\n","python",[24,1049,1050,1055,1063,1076,1080,1110,1114,1128,1142],{"__ignoreMap":255},[259,1051,1052],{"class":104,"line":261},[259,1053,1054],{"class":291},"# app\u002Fmain.py\n",[259,1056,1057,1060],{"class":104,"line":268},[259,1058,1059],{"class":396},"import",[259,1061,1062],{"class":400}," os\n",[259,1064,1065,1068,1071,1073],{"class":104,"line":279},[259,1066,1067],{"class":396},"from",[259,1069,1070],{"class":400}," fastapi ",[259,1072,1059],{"class":396},[259,1074,1075],{"class":400}," FastAPI\n",[259,1077,1078],{"class":104,"line":295},[259,1079,483],{"emptyLinePlaceholder":482},[259,1081,1082,1085,1088,1091,1094,1096,1099,1102,1104,1107],{"class":104,"line":303},[259,1083,1084],{"class":400},"app ",[259,1086,1087],{"class":396},"=",[259,1089,1090],{"class":400}," FastAPI(",[259,1092,48],{"class":1093},"s4XuR",[259,1095,1087],{"class":396},[259,1097,1098],{"class":400},"os.getenv(",[259,1100,1101],{"class":275},"\"APP_NAME\"",[259,1103,831],{"class":400},[259,1105,1106],{"class":275},"\"api\"",[259,1108,1109],{"class":400},"))\n",[259,1111,1112],{"class":104,"line":311},[259,1113,483],{"emptyLinePlaceholder":482},[259,1115,1116,1119,1122,1125],{"class":104,"line":319},[259,1117,1118],{"class":271},"@app.get",[259,1120,1121],{"class":400},"(",[259,1123,1124],{"class":275},"\"\u002Fhealth\"",[259,1126,1127],{"class":400},")\n",[259,1129,1130,1133,1136,1139],{"class":104,"line":327},[259,1131,1132],{"class":396},"async",[259,1134,1135],{"class":396}," def",[259,1137,1138],{"class":271}," health",[259,1140,1141],{"class":400},"():\n",[259,1143,1144,1147,1150,1153,1156,1159,1161,1164,1167,1170,1172,1175],{"class":104,"line":419},[259,1145,1146],{"class":396},"    return",[259,1148,1149],{"class":400}," {",[259,1151,1152],{"class":275},"\"status\"",[259,1154,1155],{"class":400},": ",[259,1157,1158],{"class":275},"\"ok\"",[259,1160,831],{"class":400},[259,1162,1163],{"class":275},"\"version\"",[259,1165,1166],{"class":400},": os.getenv(",[259,1168,1169],{"class":275},"\"APP_VERSION\"",[259,1171,831],{"class":400},[259,1173,1174],{"class":275},"\"dev\"",[259,1176,1177],{"class":400},")}\n",[14,1179,1180,1181,1184,1185,1188],{},"The ",[24,1182,1183],{},"WEB_CONCURRENCY"," variable is a convention many platforms (Render, Railway, Heroku) set automatically based on the instance size, which is why reading it from the environment makes your image portable across hosts. The starting rule of thumb is ",[24,1186,1187],{},"(2 × vCPU) + 1",", but treat that as a hypothesis to load-test, not a law. For a genuinely async, I\u002FO-bound API — one that spends most of its time awaiting a database or an upstream HTTP call — you often serve more concurrency per worker than the formula predicts, because a single uvicorn worker interleaves thousands of awaiting requests on one event loop.",[14,1190,1191,1192,1196,1197,1201,1202,1205],{},"There is a coupling most builders miss: worker count multiplies your database connections. If each of 4 workers opens a pool of 10 connections, one container holds 40 connections to Postgres, and three replicas hold 120 — enough to exhaust a small managed database's connection limit and start refusing new work. Size the two together, and if you hit ceilings read ",[18,1193,1195],{"href":1194},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Ffixing-connection-pool-exhaustion\u002F","fixing connection pool exhaustion"," alongside the broader guide on ",[18,1198,1200],{"href":1199},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F","async database access with SQLAlchemy",". One heavy synchronous call — a blocking ",[24,1203,1204],{},"requests"," call inside an async route, a CPU-bound loop — stalls the whole worker's event loop and every request it was juggling, so keep the hot path fully async.",[160,1207],{},[163,1209,1211],{"id":1210},"step-4-pass-secrets-via-env-never-bake-them-in","Step 4: Pass secrets via env, never bake them in",[14,1213,1214,1215,1218,1219,1222,1223,1226],{},"Secrets go in at runtime. Anything you ",[24,1216,1217],{},"ENV DATABASE_URL=..."," or ",[24,1220,1221],{},"COPY .env"," into the image is permanently recorded in a layer and recoverable by anyone who pulls it — ",[24,1224,1225],{},"docker history --no-trunc"," prints build arguments and env instructions in plain text. Inject instead:",[250,1228,1230],{"className":252,"code":1229,"language":254,"meta":255,"style":255},"docker run --rm -p 8000:8000 \\\n  -e DATABASE_URL=\"$DATABASE_URL\" \\\n  -e REDIS_URL=\"$REDIS_URL\" \\\n  -e STRIPE_SECRET_KEY=\"$STRIPE_SECRET_KEY\" \\\n  -e WEB_CONCURRENCY=4 \\\n  your-registry\u002Fyour-api:latest\n",[24,1231,1232,1251,1267,1281,1295,1307],{"__ignoreMap":255},[259,1233,1234,1237,1240,1243,1246,1249],{"class":104,"line":261},[259,1235,1236],{"class":271},"docker",[259,1238,1239],{"class":275}," run",[259,1241,1242],{"class":264}," --rm",[259,1244,1245],{"class":264}," -p",[259,1247,1248],{"class":275}," 8000:8000",[259,1250,649],{"class":264},[259,1252,1253,1256,1259,1262,1265],{"class":104,"line":268},[259,1254,1255],{"class":264},"  -e",[259,1257,1258],{"class":275}," DATABASE_URL=\"",[259,1260,1261],{"class":400},"$DATABASE_URL",[259,1263,1264],{"class":275},"\"",[259,1266,649],{"class":264},[259,1268,1269,1271,1274,1277,1279],{"class":104,"line":279},[259,1270,1255],{"class":264},[259,1272,1273],{"class":275}," REDIS_URL=\"",[259,1275,1276],{"class":400},"$REDIS_URL",[259,1278,1264],{"class":275},[259,1280,649],{"class":264},[259,1282,1283,1285,1288,1291,1293],{"class":104,"line":295},[259,1284,1255],{"class":264},[259,1286,1287],{"class":275}," STRIPE_SECRET_KEY=\"",[259,1289,1290],{"class":400},"$STRIPE_SECRET_KEY",[259,1292,1264],{"class":275},[259,1294,649],{"class":264},[259,1296,1297,1299,1302,1305],{"class":104,"line":303},[259,1298,1255],{"class":264},[259,1300,1301],{"class":275}," WEB_CONCURRENCY=",[259,1303,1304],{"class":264},"4",[259,1306,649],{"class":264},[259,1308,1309],{"class":104,"line":311},[259,1310,1311],{"class":275},"  your-registry\u002Fyour-api:latest\n",[14,1313,1314,1315,1318,1319,1321,1322,210],{},"For local development, ",[24,1316,1317],{},"--env-file .env"," reads from a file that stays out of the image (it's in ",[24,1320,351],{},"). On a platform, you set these in the dashboard or a secret store. This is the same boundary you maintain when you ",[18,1323,1325],{"href":1324},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002F","deploy APIs to a host like Render or Vercel",[14,1327,1328,1329,1332,1333,1336,1337,1340,1341,1343,1344,1346],{},"One subtlety worth internalizing: build arguments (",[24,1330,1331],{},"ARG",") are not secrets either. They are visible in image metadata and in the build cache. If you genuinely need a secret ",[879,1334,1335],{},"at build time"," — say a token to pull from a private package index — use BuildKit's ",[24,1338,1339],{},"--mount=type=secret",", which mounts the value into a single ",[24,1342,579],{}," without ever committing it to a layer. Never pass a private-registry token as a plain ",[24,1345,1331],{},". The rule is simple and absolute: the image is a public artifact in security terms, even in a private registry, because registry access controls fail more often than you would like.",[160,1348],{},[163,1350,1352],{"id":1351},"step-5-compose-a-full-stack-with-redis-and-postgres","Step 5: Compose a full stack with Redis and Postgres",[14,1354,1355,1356,1358,1359,210],{},"A real API rarely runs alone. ",[24,1357,26],{}," wires your container to backing services so the whole stack comes up with one command — ideal for local development and for the integration tests you run when ",[18,1360,1362],{"href":1361},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","testing Python APIs with pytest",[250,1364,1368],{"className":1365,"code":1366,"language":1367,"meta":255,"style":255},"language-yaml shiki shiki-themes github-light github-dark","# docker-compose.yml\nservices:\n  api:\n    build: .\n    ports:\n      - \"8000:8000\"\n    environment:\n      DATABASE_URL: postgresql:\u002F\u002Fapp:${POSTGRES_PASSWORD}@db:5432\u002Fapp\n      REDIS_URL: redis:\u002F\u002Fcache:6379\u002F0\n      WEB_CONCURRENCY: \"4\"\n    env_file:\n      - .env\n    depends_on:\n      db:\n        condition: service_healthy\n      cache:\n        condition: service_started\n\n  db:\n    image: postgres:16-alpine\n    environment:\n      POSTGRES_USER: app\n      POSTGRES_DB: app\n      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}\n    volumes:\n      - pgdata:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U app\"]\n      interval: 5s\n      timeout: 3s\n      retries: 5\n\n  cache:\n    image: redis:7-alpine\n    command: [\"redis-server\", \"--save\", \"\", \"--appendonly\", \"no\"]\n\nvolumes:\n  pgdata:\n","yaml",[24,1369,1370,1375,1384,1391,1400,1407,1415,1422,1432,1442,1452,1459,1465,1472,1479,1489,1496,1505,1509,1516,1526,1532,1542,1551,1561,1568,1575,1582,1600,1610,1620,1630,1634,1641,1650,1682,1686,1693],{"__ignoreMap":255},[259,1371,1372],{"class":104,"line":261},[259,1373,1374],{"class":291},"# docker-compose.yml\n",[259,1376,1377,1381],{"class":104,"line":268},[259,1378,1380],{"class":1379},"s9eBZ","services",[259,1382,1383],{"class":400},":\n",[259,1385,1386,1389],{"class":104,"line":279},[259,1387,1388],{"class":1379},"  api",[259,1390,1383],{"class":400},[259,1392,1393,1396,1398],{"class":104,"line":295},[259,1394,1395],{"class":1379},"    build",[259,1397,1155],{"class":400},[259,1399,265],{"class":264},[259,1401,1402,1405],{"class":104,"line":303},[259,1403,1404],{"class":1379},"    ports",[259,1406,1383],{"class":400},[259,1408,1409,1412],{"class":104,"line":311},[259,1410,1411],{"class":400},"      - ",[259,1413,1414],{"class":275},"\"8000:8000\"\n",[259,1416,1417,1420],{"class":104,"line":319},[259,1418,1419],{"class":1379},"    environment",[259,1421,1383],{"class":400},[259,1423,1424,1427,1429],{"class":104,"line":327},[259,1425,1426],{"class":1379},"      DATABASE_URL",[259,1428,1155],{"class":400},[259,1430,1431],{"class":275},"postgresql:\u002F\u002Fapp:${POSTGRES_PASSWORD}@db:5432\u002Fapp\n",[259,1433,1434,1437,1439],{"class":104,"line":419},[259,1435,1436],{"class":1379},"      REDIS_URL",[259,1438,1155],{"class":400},[259,1440,1441],{"class":275},"redis:\u002F\u002Fcache:6379\u002F0\n",[259,1443,1444,1447,1449],{"class":104,"line":425},[259,1445,1446],{"class":1379},"      WEB_CONCURRENCY",[259,1448,1155],{"class":400},[259,1450,1451],{"class":275},"\"4\"\n",[259,1453,1454,1457],{"class":104,"line":431},[259,1455,1456],{"class":1379},"    env_file",[259,1458,1383],{"class":400},[259,1460,1461,1463],{"class":104,"line":437},[259,1462,1411],{"class":400},[259,1464,406],{"class":275},[259,1466,1467,1470],{"class":104,"line":548},[259,1468,1469],{"class":1379},"    depends_on",[259,1471,1383],{"class":400},[259,1473,1474,1477],{"class":104,"line":557},[259,1475,1476],{"class":1379},"      db",[259,1478,1383],{"class":400},[259,1480,1481,1484,1486],{"class":104,"line":562},[259,1482,1483],{"class":1379},"        condition",[259,1485,1155],{"class":400},[259,1487,1488],{"class":275},"service_healthy\n",[259,1490,1491,1494],{"class":104,"line":568},[259,1492,1493],{"class":1379},"      cache",[259,1495,1383],{"class":400},[259,1497,1498,1500,1502],{"class":104,"line":576},[259,1499,1483],{"class":1379},[259,1501,1155],{"class":400},[259,1503,1504],{"class":275},"service_started\n",[259,1506,1507],{"class":104,"line":585},[259,1508,483],{"emptyLinePlaceholder":482},[259,1510,1511,1514],{"class":104,"line":591},[259,1512,1513],{"class":1379},"  db",[259,1515,1383],{"class":400},[259,1517,1518,1521,1523],{"class":104,"line":596},[259,1519,1520],{"class":1379},"    image",[259,1522,1155],{"class":400},[259,1524,1525],{"class":275},"postgres:16-alpine\n",[259,1527,1528,1530],{"class":104,"line":602},[259,1529,1419],{"class":1379},[259,1531,1383],{"class":400},[259,1533,1534,1537,1539],{"class":104,"line":614},[259,1535,1536],{"class":1379},"      POSTGRES_USER",[259,1538,1155],{"class":400},[259,1540,1541],{"class":275},"app\n",[259,1543,1544,1547,1549],{"class":104,"line":619},[259,1545,1546],{"class":1379},"      POSTGRES_DB",[259,1548,1155],{"class":400},[259,1550,1541],{"class":275},[259,1552,1553,1556,1558],{"class":104,"line":625},[259,1554,1555],{"class":1379},"      POSTGRES_PASSWORD",[259,1557,1155],{"class":400},[259,1559,1560],{"class":275},"${POSTGRES_PASSWORD}\n",[259,1562,1563,1566],{"class":104,"line":633},[259,1564,1565],{"class":1379},"    volumes",[259,1567,1383],{"class":400},[259,1569,1570,1572],{"class":104,"line":638},[259,1571,1411],{"class":400},[259,1573,1574],{"class":275},"pgdata:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata\n",[259,1576,1577,1580],{"class":104,"line":652},[259,1578,1579],{"class":1379},"    healthcheck",[259,1581,1383],{"class":400},[259,1583,1584,1587,1590,1593,1595,1598],{"class":104,"line":658},[259,1585,1586],{"class":1379},"      test",[259,1588,1589],{"class":400},": [",[259,1591,1592],{"class":275},"\"CMD-SHELL\"",[259,1594,831],{"class":400},[259,1596,1597],{"class":275},"\"pg_isready -U app\"",[259,1599,866],{"class":400},[259,1601,1602,1605,1607],{"class":104,"line":664},[259,1603,1604],{"class":1379},"      interval",[259,1606,1155],{"class":400},[259,1608,1609],{"class":275},"5s\n",[259,1611,1612,1615,1617],{"class":104,"line":669},[259,1613,1614],{"class":1379},"      timeout",[259,1616,1155],{"class":400},[259,1618,1619],{"class":275},"3s\n",[259,1621,1622,1625,1627],{"class":104,"line":675},[259,1623,1624],{"class":1379},"      retries",[259,1626,1155],{"class":400},[259,1628,1629],{"class":264},"5\n",[259,1631,1632],{"class":104,"line":683},[259,1633,483],{"emptyLinePlaceholder":482},[259,1635,1636,1639],{"class":104,"line":688},[259,1637,1638],{"class":1379},"  cache",[259,1640,1383],{"class":400},[259,1642,1643,1645,1647],{"class":104,"line":696},[259,1644,1520],{"class":1379},[259,1646,1155],{"class":400},[259,1648,1649],{"class":275},"redis:7-alpine\n",[259,1651,1652,1655,1657,1660,1662,1665,1667,1670,1672,1675,1677,1680],{"class":104,"line":704},[259,1653,1654],{"class":1379},"    command",[259,1656,1589],{"class":400},[259,1658,1659],{"class":275},"\"redis-server\"",[259,1661,831],{"class":400},[259,1663,1664],{"class":275},"\"--save\"",[259,1666,831],{"class":400},[259,1668,1669],{"class":275},"\"\"",[259,1671,831],{"class":400},[259,1673,1674],{"class":275},"\"--appendonly\"",[259,1676,831],{"class":400},[259,1678,1679],{"class":275},"\"no\"",[259,1681,866],{"class":400},[259,1683,1684],{"class":104,"line":709},[259,1685,483],{"emptyLinePlaceholder":482},[259,1687,1688,1691],{"class":104,"line":718},[259,1689,1690],{"class":1379},"volumes",[259,1692,1383],{"class":400},[259,1694,1695,1698],{"class":104,"line":727},[259,1696,1697],{"class":1379},"  pgdata",[259,1699,1383],{"class":400},[14,1701,1702,1703,1706,1707,1709,1710,1713,1714,1717,1718,1721,1722,1726,1727,1731],{},"Note ",[24,1704,1705],{},"${POSTGRES_PASSWORD}"," interpolates from your shell or ",[24,1708,339],{}," — the password is never hardcoded in the compose file. The ",[24,1711,1712],{},"depends_on"," with ",[24,1715,1716],{},"condition: service_healthy"," is the important part: it holds the API back until Postgres actually accepts connections, not merely until the container process starts. Without it, your API boots, tries to connect to a database that is still initializing, and crash-loops for the first few seconds of every ",[24,1719,1720],{},"up",". Once Redis is in the stack, you're a short step from ",[18,1723,1725],{"href":1724},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","caching Python API responses with Redis"," to cut latency and cost, and adding a Celery worker service for ",[18,1728,1730],{"href":1729},"\u002Fscaling-and-operating-production-python-apis\u002Frunning-background-jobs-with-celery\u002F","running background jobs"," is the same pattern: another service that shares the image and reads the same env.",[37,1733,46,1738,46,1741,46,1744,46,1751,46,1754,46,1761,46,1766,46,1770,46,1776,46,1781,46,1785,46,1789,46,1795,46,1800,46,1804,46,1808,46,1811,46,1816,46,1820,46,1823,46,1827,46,1831,46,1834,46,1838,46,1841],{"viewBox":1734,"role":40,"ariaLabelledBy":1735,"xmlns":44,"style":45},"0 0 720 320",[1736,1737],"dkr-stack-t","dkr-stack-d",[48,1739,1740],{"id":1736},"Compose stack topology",[52,1742,1743],{"id":1737},"A host port maps into the API container, which connects over the internal compose network to a Postgres service with a persistent volume and a Redis cache; the API waits on the database healthcheck before starting.",[56,1745,58,1746,46],{},[60,1747,68,1749,58],{"id":1748,"viewBox":63,"refX":64,"refY":65,"markerWidth":66,"markerHeight":66,"orient":67},"dkr-stack-arrow",[70,1750],{"d":72,"fill":73},[75,1752],{"x":77,"y":77,"width":78,"height":1753,"fill":80},"320",[75,1755],{"x":1756,"y":1757,"width":1757,"height":1758,"rx":1759,"fill":940,"stroke":1760,"style":89},"24","120","56","8","var(--c-border)",[91,1762,1765],{"x":1763,"y":1764,"fill":95,"style":944},"84","145","Host",[91,1767,1769],{"x":1763,"y":1768,"fill":73,"style":101},"163",":8000",[104,1771],{"x1":1772,"y1":1773,"x2":1774,"y2":1773,"stroke":73,"style":1775},"144","148","196","stroke-width:2;marker-end:url(#dkr-stack-arrow);",[75,1777],{"x":1774,"y":1778,"width":1779,"height":1780,"rx":87,"fill":80,"stroke":112,"style":89},"112","170","72",[91,1782,1784],{"x":115,"y":1783,"fill":95,"style":96},"140","api",[91,1786,1788],{"x":115,"y":1787,"fill":73,"style":101},"160","gunicorn + uvicorn",[104,1790],{"x1":1791,"y1":1792,"x2":1793,"y2":1794,"stroke":73,"style":1775},"366","132","516","88",[91,1796,1799],{"x":1797,"y":1798,"fill":73,"style":101},"440","98","5432",[104,1801],{"x1":1791,"y1":1802,"x2":1793,"y2":1803,"stroke":73,"style":1775},"164","210",[91,1805,1807],{"x":1797,"y":1806,"fill":73,"style":101},"205","6379",[75,1809],{"x":1793,"y":1810,"width":928,"height":1780,"rx":87,"fill":80,"stroke":126,"style":89},"48",[91,1812,1815],{"x":1813,"y":1814,"fill":95,"style":96},"606","76","db (postgres:16)",[91,1817,1819],{"x":1813,"y":1818,"fill":73,"style":101},"96","healthcheck gates api",[75,1821],{"x":1793,"y":1822,"width":928,"height":1780,"rx":87,"fill":80,"stroke":141,"style":89},"184",[91,1824,1826],{"x":1813,"y":1825,"fill":95,"style":96},"212","cache (redis:7)",[91,1828,1830],{"x":1813,"y":1829,"fill":73,"style":101},"232","ephemeral, no persistence",[75,1832],{"x":933,"y":1833,"width":1792,"height":929,"rx":939,"fill":940,"stroke":1760,"style":89},"278",[91,1835,1837],{"x":1813,"y":1836,"fill":95,"style":101},"298","pgdata volume",[104,1839],{"x1":1813,"y1":1840,"x2":1813,"y2":1833,"stroke":73,"style":1775},"256",[91,1842,1844],{"x":115,"y":1843,"fill":73,"style":101},"230","one image, injected env, internal DNS by service name",[160,1846],{},[163,1848,1850],{"id":1849},"step-6-handle-signals-so-deploys-drain-cleanly","Step 6: Handle signals so deploys drain cleanly",[14,1852,1853,1854,1857,1858,1861,1862,1865,1866,1869,1870,1872,1873,1876,1877,1880,1881,1883,1884,1887],{},"The reason the Dockerfile uses ",[24,1855,1856],{},"exec gunicorn"," instead of a bare ",[24,1859,1860],{},"gunicorn"," matters most at deploy time. When your platform ships a new version, it sends ",[24,1863,1864],{},"SIGTERM"," to PID 1 of the old container and waits a grace period before sending an unstoppable ",[24,1867,1868],{},"SIGKILL",". If PID 1 is a shell that spawned gunicorn as a child, the shell swallows ",[24,1871,1864],{},", gunicorn never hears it, and the container is hard-killed at the end of the grace window — dropping every in-flight request. With ",[24,1874,1875],{},"exec",", gunicorn ",[879,1878,1879],{},"becomes"," PID 1, receives ",[24,1882,1864],{}," directly, stops accepting new connections, lets in-flight requests finish within ",[24,1885,1886],{},"--graceful-timeout",", and exits cleanly.",[14,1889,1890,1891,1893,1894,1896,1897,1900,1901,1218,1904,1907],{},"This is the mechanical foundation under ",[18,1892,34],{"href":33},": a deploy is only zero-downtime if the outgoing container drains rather than drops. Set ",[24,1895,1886],{}," comfortably above your slowest normal request but below the platform's kill window, so gunicorn always finishes on its own terms. If your app spawns child processes and you want a real init to reap zombies and forward signals, add ",[24,1898,1899],{},"--init"," on ",[24,1902,1903],{},"docker run",[24,1905,1906],{},"init: true"," in compose rather than shipping your own wrapper.",[37,1909,46,1914,46,1917,46,1920,46,1923,46,1927,46,1929,46,1931,46,1935,46,1937,46,1939,46,1942,46,1945,46,1950,46,1952,46,1955,46,1959,46,1963,46,1966,46,1968,46,1971,46,1974],{"viewBox":1910,"role":40,"ariaLabelledBy":1911,"xmlns":44,"style":45},"0 0 720 250",[1912,1913],"dkr-sig-t","dkr-sig-d",[48,1915,1916],{"id":1912},"Graceful shutdown timeline on deploy",[52,1918,1919],{"id":1913},"The platform sends SIGTERM to gunicorn as PID 1, which stops accepting connections, drains in-flight requests within the graceful timeout, and exits before the SIGKILL deadline.",[75,1921],{"x":77,"y":77,"width":78,"height":1922,"fill":80},"250",[104,1924],{"x1":152,"y1":1925,"x2":1926,"y2":1925,"stroke":1760,"style":89},"130","680",[104,1928],{"x1":84,"y1":1757,"x2":84,"y2":1783,"stroke":112,"style":89},[91,1930,145],{"x":84,"y":1778,"fill":95,"style":101},[91,1932,1934],{"x":84,"y":1933,"fill":73,"style":101},"158","new image",[104,1936],{"x1":1843,"y1":1757,"x2":1843,"y2":1783,"stroke":88,"style":89},[91,1938,1864],{"x":1843,"y":1778,"fill":95,"style":101},[91,1940,1941],{"x":1843,"y":1933,"fill":73,"style":101},"to PID 1",[75,1943],{"x":1843,"y":1779,"width":1843,"height":1944,"rx":939,"fill":940,"stroke":126,"style":89},"34",[91,1946,1949],{"x":1947,"y":1948,"fill":95,"style":944},"345","192","drain in-flight (graceful-timeout)",[104,1951],{"x1":1947,"y1":1757,"x2":1947,"y2":1783,"stroke":126,"style":89},[91,1953,1954],{"x":1947,"y":1778,"fill":95,"style":101},"stop accept",[104,1956],{"x1":1957,"y1":140,"x2":1957,"y2":1958,"stroke":126,"style":89},"460","142",[91,1960,1962],{"x":1957,"y":1961,"fill":95,"style":101},"108","clean exit",[104,1964],{"x1":1965,"y1":140,"x2":1965,"y2":1958,"stroke":141,"style":89},"620",[91,1967,1868],{"x":1965,"y":1961,"fill":95,"style":101},[91,1969,1970],{"x":1965,"y":1933,"fill":73,"style":101},"deadline",[91,1972,1973],{"x":151,"y":152,"fill":95,"style":153},"exec form: gunicorn is PID 1 and exits before SIGKILL",[91,1975,1977],{"x":151,"y":1976,"fill":141,"style":944},"66","shell form: SIGTERM swallowed → hard kill drops requests",[160,1979],{},[163,1981,1983],{"id":1982},"step-7-build-for-the-right-architecture","Step 7: Build for the right architecture",[14,1985,1986,1987,1990],{},"If you develop on an Apple Silicon Mac and deploy to an x86 host, a plain ",[24,1988,1989],{},"docker build"," produces an arm64 image that either refuses to run or silently falls back to slow emulation on your amd64 server. Build explicitly for the target platform. For a single target:",[250,1992,1994],{"className":252,"code":1993,"language":254,"meta":255,"style":255},"docker buildx build \\\n  --platform linux\u002Famd64 \\\n  -t your-registry\u002Fyour-api:$(git rev-parse --short HEAD) \\\n  --push .\n",[24,1995,1996,2008,2018,2047],{"__ignoreMap":255},[259,1997,1998,2000,2003,2006],{"class":104,"line":261},[259,1999,1236],{"class":271},[259,2001,2002],{"class":275}," buildx",[259,2004,2005],{"class":275}," build",[259,2007,649],{"class":264},[259,2009,2010,2013,2016],{"class":104,"line":268},[259,2011,2012],{"class":264},"  --platform",[259,2014,2015],{"class":275}," linux\u002Famd64",[259,2017,649],{"class":264},[259,2019,2020,2023,2026,2029,2032,2035,2038,2041,2044],{"class":104,"line":279},[259,2021,2022],{"class":264},"  -t",[259,2024,2025],{"class":275}," your-registry\u002Fyour-api:",[259,2027,2028],{"class":400},"$(",[259,2030,2031],{"class":271},"git",[259,2033,2034],{"class":275}," rev-parse",[259,2036,2037],{"class":264}," --short",[259,2039,2040],{"class":275}," HEAD",[259,2042,2043],{"class":400},") ",[259,2045,2046],{"class":264},"\\\n",[259,2048,2049,2052],{"class":104,"line":295},[259,2050,2051],{"class":264},"  --push",[259,2053,2054],{"class":275}," .\n",[14,2056,2057,2058,2061,2062,2065],{},"Tag with the git SHA, not just ",[24,2059,2060],{},"latest",", so every deploy is traceable to a commit and a rollback is a one-line retag. Many managed hosts build the image for you from your repo, sidestepping this entirely, but the moment you build locally and push, architecture is a real failure mode — one of the most common \"it ran on my laptop and 500s in production\" bugs. If you serve both amd64 and arm64 infrastructure, ",[24,2063,2064],{},"--platform linux\u002Famd64,linux\u002Farm64"," emits a multi-arch manifest and the registry hands each host the right variant.",[160,2067],{},[163,2069,2071],{"id":2070},"configuration-reference","Configuration reference",[2073,2074,2075,2091],"table",{},[2076,2077,2078],"thead",{},[2079,2080,2081,2085,2088],"tr",{},[2082,2083,2084],"th",{},"Env var",[2082,2086,2087],{},"Default",[2082,2089,2090],{},"Production recommendation",[2092,2093,2094,2108,2123,2137,2152,2165,2177,2195],"tbody",{},[2079,2095,2096,2101,2105],{},[2097,2098,2099],"td",{},[24,2100,787],{},[2097,2102,2103],{},[24,2104,797],{},[2097,2106,2107],{},"Match the platform's expected port; many set it",[2079,2109,2110,2114,2118],{},[2097,2111,2112],{},[24,2113,1183],{},[2097,2115,2116],{},[24,2117,1304],{},[2097,2119,2120,2122],{},[24,2121,1187],{},", then load-test and adjust",[2079,2124,2125,2130,2134],{},[2097,2126,2127],{},[24,2128,2129],{},"APP_NAME",[2097,2131,2132],{},[24,2133,1784],{},[2097,2135,2136],{},"Service name, for logs and OpenAPI title",[2079,2138,2139,2144,2149],{},[2097,2140,2141],{},[24,2142,2143],{},"APP_VERSION",[2097,2145,2146],{},[24,2147,2148],{},"dev",[2097,2150,2151],{},"Inject the git SHA or release tag at deploy",[2079,2153,2154,2159,2162],{},[2097,2155,2156],{},[24,2157,2158],{},"DATABASE_URL",[2097,2160,2161],{},"unset",[2097,2163,2164],{},"Injected secret; never baked into a layer",[2079,2166,2167,2172,2174],{},[2097,2168,2169],{},[24,2170,2171],{},"REDIS_URL",[2097,2173,2161],{},[2097,2175,2176],{},"Injected secret; use a managed Redis in prod",[2079,2178,2179,2184,2189],{},[2097,2180,2181],{},[24,2182,2183],{},"PYTHONUNBUFFERED",[2097,2185,2186],{},[24,2187,2188],{},"1",[2097,2190,2191,2192,2194],{},"Keep ",[24,2193,2188],{}," so logs stream immediately",[2079,2196,2197,2202,2206],{},[2097,2198,2199],{},[24,2200,2201],{},"UV_COMPILE_BYTECODE",[2097,2203,2204],{},[24,2205,2188],{},[2097,2207,2208,2209,2212],{},"Precompile ",[24,2210,2211],{},".pyc"," for faster cold starts",[14,2214,2191,2215,2218],{},[24,2216,2217],{},"PYTHONUNBUFFERED=1"," in particular: without it, Python buffers stdout and your logs arrive in bursts or vanish entirely when a container is killed, which turns an incident into a guessing game. Every value here is read from the environment, so the same image behaves correctly whether it runs under compose locally or under your host's orchestrator.",[160,2220],{},[163,2222,2224],{"id":2223},"gotchas-and-failure-modes","Gotchas and failure modes",[2226,2227,2228,2247,2260,2278,2291,2309],"ol",{},[174,2229,2230,2233,2234,1218,2237,2239,2240,2243,2244,2246],{},[177,2231,2232],{},"Baking secrets into layers."," ",[24,2235,2236],{},"ENV API_KEY=...",[24,2238,1221],{}," writes the secret into image history forever. ",[24,2241,2242],{},"docker history"," and a registry pull expose it. Inject at runtime only, and remember ",[24,2245,1331],{}," is not a secret either.",[174,2248,2249,2252,2253,2255,2256,2259],{},[177,2250,2251],{},"Running as root."," The default user is root. A container escape or a compromised dependency then runs with root inside the container. Always create and ",[24,2254,712],{}," a non-privileged account, and copy application files with ",[24,2257,2258],{},"--chown"," so the non-root user can read them.",[174,2261,2262,2265,2266,2268,2269,2272,2273,2275,2276,210],{},[177,2263,2264],{},"Huge images."," Installing into the base ",[24,2267,1005],{}," (not ",[24,2270,2271],{},"-slim","), skipping multi-stage, or forgetting ",[24,2274,351],{}," produces 1GB+ images. Bigger images mean slower pulls, slower cold starts, and higher egress. Fix it with the techniques in ",[18,2277,465],{"href":464},[174,2279,2280,2285,2286,831,2288,2290],{},[177,2281,2282,2283,210],{},"Missing ",[24,2284,351],{}," Without it, ",[24,2287,355],{},[24,2289,359],{},", and caches inflate the build context and can leak secrets. Write it first.",[174,2292,2293,2296,2297,2233,2300,2302,2303,2305,2306,2308],{},[177,2294,2295],{},"PID 1 and signal handling."," If you start gunicorn through a shell ",[879,2298,2299],{},"without",[24,2301,1875],{},", the shell becomes PID 1 and swallows ",[24,2304,1864],{},". The container then takes the full kill timeout to stop on every deploy, dropping in-flight requests. Use ",[24,2307,1875],{}," so gunicorn is PID 1.",[174,2310,2311,2316,2317,2320],{},[177,2312,2313,2314,210],{},"Running migrations in ",[24,2315,822],{}," If your entrypoint runs ",[24,2318,2319],{},"alembic upgrade head"," and you scale to multiple replicas, every replica races to migrate the same database on boot. Run migrations as a separate pre-deploy step instead.",[160,2322],{},[163,2324,2326],{"id":2325},"verification","Verification",[14,2328,2329],{},"Build, run, and prove the healthcheck responds:",[250,2331,2333],{"className":252,"code":2332,"language":254,"meta":255,"style":255},"# Build and tag\ndocker build -t your-api:local .\n\n# Inspect the size — a slim multi-stage build should land well under 300MB\ndocker images your-api:local\n\n# Run with config injected\ndocker run --rm -d --name api-test -p 8000:8000 \\\n  -e WEB_CONCURRENCY=2 your-api:local\n\n# The healthcheck endpoint should return 200\ncurl -fsS http:\u002F\u002Flocalhost:8000\u002Fhealth\n# {\"status\":\"ok\",\"version\":\"dev\"}\n\n# Docker's own healthcheck should report \"healthy\" after the start period\ndocker inspect --format '{{.State.Health.Status}}' api-test\n\n# Confirm you are NOT running as root inside the container\ndocker exec api-test whoami   # -> app\n\ndocker rm -f api-test\n",[24,2334,2335,2340,2354,2358,2363,2373,2377,2382,2405,2416,2420,2425,2436,2441,2445,2450,2466,2470,2475,2490,2494],{"__ignoreMap":255},[259,2336,2337],{"class":104,"line":261},[259,2338,2339],{"class":291},"# Build and tag\n",[259,2341,2342,2344,2346,2349,2352],{"class":104,"line":268},[259,2343,1236],{"class":271},[259,2345,2005],{"class":275},[259,2347,2348],{"class":264}," -t",[259,2350,2351],{"class":275}," your-api:local",[259,2353,2054],{"class":275},[259,2355,2356],{"class":104,"line":279},[259,2357,483],{"emptyLinePlaceholder":482},[259,2359,2360],{"class":104,"line":295},[259,2361,2362],{"class":291},"# Inspect the size — a slim multi-stage build should land well under 300MB\n",[259,2364,2365,2367,2370],{"class":104,"line":303},[259,2366,1236],{"class":271},[259,2368,2369],{"class":275}," images",[259,2371,2372],{"class":275}," your-api:local\n",[259,2374,2375],{"class":104,"line":311},[259,2376,483],{"emptyLinePlaceholder":482},[259,2378,2379],{"class":104,"line":319},[259,2380,2381],{"class":291},"# Run with config injected\n",[259,2383,2384,2386,2388,2390,2393,2396,2399,2401,2403],{"class":104,"line":327},[259,2385,1236],{"class":271},[259,2387,1239],{"class":275},[259,2389,1242],{"class":264},[259,2391,2392],{"class":264}," -d",[259,2394,2395],{"class":264}," --name",[259,2397,2398],{"class":275}," api-test",[259,2400,1245],{"class":264},[259,2402,1248],{"class":275},[259,2404,649],{"class":264},[259,2406,2407,2409,2411,2414],{"class":104,"line":419},[259,2408,1255],{"class":264},[259,2410,1301],{"class":275},[259,2412,2413],{"class":264},"2",[259,2415,2372],{"class":275},[259,2417,2418],{"class":104,"line":425},[259,2419,483],{"emptyLinePlaceholder":482},[259,2421,2422],{"class":104,"line":431},[259,2423,2424],{"class":291},"# The healthcheck endpoint should return 200\n",[259,2426,2427,2430,2433],{"class":104,"line":437},[259,2428,2429],{"class":271},"curl",[259,2431,2432],{"class":264}," -fsS",[259,2434,2435],{"class":275}," http:\u002F\u002Flocalhost:8000\u002Fhealth\n",[259,2437,2438],{"class":104,"line":548},[259,2439,2440],{"class":291},"# {\"status\":\"ok\",\"version\":\"dev\"}\n",[259,2442,2443],{"class":104,"line":557},[259,2444,483],{"emptyLinePlaceholder":482},[259,2446,2447],{"class":104,"line":562},[259,2448,2449],{"class":291},"# Docker's own healthcheck should report \"healthy\" after the start period\n",[259,2451,2452,2454,2457,2460,2463],{"class":104,"line":568},[259,2453,1236],{"class":271},[259,2455,2456],{"class":275}," inspect",[259,2458,2459],{"class":264}," --format",[259,2461,2462],{"class":275}," '{{.State.Health.Status}}'",[259,2464,2465],{"class":275}," api-test\n",[259,2467,2468],{"class":104,"line":576},[259,2469,483],{"emptyLinePlaceholder":482},[259,2471,2472],{"class":104,"line":585},[259,2473,2474],{"class":291},"# Confirm you are NOT running as root inside the container\n",[259,2476,2477,2479,2482,2484,2487],{"class":104,"line":591},[259,2478,1236],{"class":271},[259,2480,2481],{"class":275}," exec",[259,2483,2398],{"class":275},[259,2485,2486],{"class":275}," whoami",[259,2488,2489],{"class":291},"   # -> app\n",[259,2491,2492],{"class":104,"line":596},[259,2493,483],{"emptyLinePlaceholder":482},[259,2495,2496,2498,2501,2504],{"class":104,"line":602},[259,2497,1236],{"class":271},[259,2499,2500],{"class":275}," rm",[259,2502,2503],{"class":264}," -f",[259,2505,2465],{"class":275},[14,2507,2508,2509,2512,2513,2516,2517,2520,2521,2524,2525,2528,2529,2532],{},"For the full stack: ",[24,2510,2511],{},"docker compose up --build",", then hit ",[24,2514,2515],{},"http:\u002F\u002Flocalhost:8000\u002Fhealth"," and confirm the ",[24,2518,2519],{},"db"," container reports ",[24,2522,2523],{},"healthy"," in ",[24,2526,2527],{},"docker compose ps",". To prove graceful shutdown, send a slow request and ",[24,2530,2531],{},"docker stop"," the container mid-flight — a correctly configured image finishes the request before exiting instead of dropping the connection.",[160,2534],{},[163,2536,2538],{"id":2537},"cost-and-performance-analysis","Cost and performance analysis",[14,2540,2541,2542,2544,2545,2547,2548,2550],{},"Image size is not vanity — it's a line item. Consider the three ways the same FastAPI app commonly gets packaged. The full ",[24,2543,1005],{}," base with a single-stage build and no ",[24,2546,351],{}," lands around 1.2GB. A single-stage ",[24,2549,1001],{}," build cuts that to roughly 450MB but still ships pip caches and build headers. The multi-stage slim build in this guide lands near 180MB. Those numbers drive real costs.",[37,2552,46,2556,46,2559,46,2562,46,2564,46,2567,46,2573,46,2577,46,2583,46,2586,46,2589,46,2594,46,2598,46,2600],{"viewBox":39,"role":40,"ariaLabelledBy":2553,"xmlns":44,"style":45},[2554,2555],"dkr-size-t","dkr-size-d",[48,2557,2558],{"id":2554},"Image size by packaging strategy",[52,2560,2561],{"id":2555},"A full base single-stage image is about 1.2 gigabytes, a slim single-stage image about 450 megabytes, and a slim multi-stage image about 180 megabytes.",[75,2563],{"x":77,"y":77,"width":78,"height":79,"fill":80},[91,2565,2566],{"x":151,"y":929,"fill":95,"style":153},"Final image size (lower is cheaper and faster)",[91,2568,2572],{"x":2569,"y":2570,"fill":95,"style":2571},"20","74","font-size:12;font-family:var(--font-sans);","full base, single stage",[75,2574],{"x":157,"y":2575,"width":2576,"height":929,"rx":1304,"fill":940,"stroke":141,"style":89},"60","500",[91,2578,2582],{"x":2579,"y":2580,"fill":95,"style":2581},"690","80","text-anchor:end;font-size:12;font-family:var(--font-sans);","~1200 MB",[91,2584,2585],{"x":2569,"y":100,"fill":95,"style":2571},"slim, single stage",[75,2587],{"x":157,"y":1757,"width":2588,"height":929,"rx":1304,"fill":940,"stroke":88,"style":89},"188",[91,2590,2593],{"x":2591,"y":1783,"fill":95,"style":2592},"398","text-anchor:start;font-size:12;font-family:var(--font-sans);"," ~450 MB",[91,2595,2597],{"x":2569,"y":2596,"fill":95,"style":2571},"194","slim, multi-stage",[75,2599],{"x":157,"y":928,"width":943,"height":929,"rx":1304,"fill":940,"stroke":126,"style":89},[91,2601,2603],{"x":2602,"y":157,"fill":95,"style":2592},"285"," ~180 MB",[14,2605,2606,2607,2610,2611,2615,2616,2619],{},"On serverless or autoscaling platforms, pull time ",[879,2608,2609],{},"is"," part of request latency for the unlucky first user after a scale-up: dropping from 1.2GB to 180MB can shave several seconds off a cold start, which is the difference between a snappy first response and a timeout. Registry storage and egress are billed per gigabyte, so a fleet pushing dozens of tagged images a day compounds quickly. And the build-minute savings from good layer caching — ten-second cache hits instead of three-minute full rebuilds — land straight on your CI bill. None of these are large in isolation; together they are the quiet operational tax that separates a healthy API margin from a thin one. When you're ready to pick a host, cold-start characteristics differ sharply across platforms — see ",[18,2612,2614],{"href":2613},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Frender-vs-railway-vs-fly-io\u002F","Render vs Railway vs Fly.io"," and the broader ",[18,2617,2618],{"href":1324},"deploying APIs"," guide.",[160,2621],{},[163,2623,2625],{"id":2624},"faq","FAQ",[14,2627,2628,2631,2632,2634],{},[177,2629,2630],{},"How much does it cost to run a containerized Python API at 1M requests a month?","\nThe container itself is cheap: a single small instance (1 vCPU, ~1GB RAM) running a slim image handles 1M requests a month — roughly 0.4 requests a second averaged out, with headroom for peaks — for a few dollars of compute. The real costs sit around it: managed Postgres, managed Redis, egress, and registry storage. A bloated 1.2GB image inflates the last two and slows autoscaling, so keeping the image at ~180MB protects your margin as you grow. Match ",[24,2633,1183],{}," to vCPUs and you rarely need a bigger box before you are well past a million requests.",[14,2636,2637,2640,2641,2643,2644,2646,2647,2650],{},[177,2638,2639],{},"Should I use uv or pip in my Dockerfile?","\nEither works. ",[24,2642,230],{}," is dramatically faster and produces deterministic installs from a lockfile, which pays off most in CI where you rebuild often and pay per build-minute. If your project already uses ",[24,2645,219],{}," and you don't want to migrate, ",[24,2648,2649],{},"pip install --no-cache-dir -r requirements.txt"," in the builder stage is perfectly fine. The multi-stage pattern and layer ordering matter far more to your cost and speed than the installer choice.",[14,2652,2653,2656,2657,2660,2661,2663],{},[177,2654,2655],{},"How do I handle database migrations in a container without downtime?","\nRun migrations as a separate step before the new image takes traffic — a one-off ",[24,2658,2659],{},"docker run your-api alembic upgrade head"," job or a release\u002Fpre-deploy hook — never inside your ",[24,2662,822],{},". Running migrations in the app entrypoint races when you scale to multiple replicas and can corrupt schema state. Keep migrations backward-compatible with the currently-running version so the old and new images can briefly coexist during a rolling deploy.",[14,2665,2666,2669,2670,2672,2673,2675],{},[177,2667,2668],{},"Can I put Stripe keys or my JWT secret in the Dockerfile for convenience?","\nNo. Anything in the Dockerfile, an ",[24,2671,1331],{},", or copied into the image is permanently in a layer and recoverable with ",[24,2674,2242],{}," or a registry pull. Inject every secret at runtime via environment variables or a secret manager. This keeps the same image safe to reuse across staging and production and safe to push to a shared registry — a discipline that also makes key rotation a config change rather than an image rebuild.",[14,2677,2678,2681,2682,2684,2685,2687,2688,2690,2691,1900,2693,2695],{},[177,2679,2680],{},"Why is my container ignoring Ctrl+C or taking the full timeout to stop?","\nThat's the PID 1 signal problem. Your process isn't receiving ",[24,2683,1864],{}," because a shell is PID 1 and swallowing it. Use the ",[24,2686,1875],{}," form so your server is PID 1, or add ",[24,2689,1906],{}," in compose \u002F ",[24,2692,1899],{},[24,2694,1903],{}," to get a proper init that forwards signals. Until you fix this, every deploy drops in-flight requests and takes the maximum grace period to complete.",[160,2697],{},[163,2699,2701],{"id":2700},"related","Related",[14,2703,2704],{},[177,2705,2706],{},"Same track:",[171,2708,2709,2715,2721,2727],{},[174,2710,2711,2714],{},[18,2712,2713],{"href":464},"Optimizing Python Docker image size"," — squeeze the image further once the multi-stage base is in place.",[174,2716,2717,2720],{},[18,2718,2719],{"href":1724},"Caching Python API responses with Redis"," — the natural next service in your compose stack.",[174,2722,2723,2726],{},[18,2724,2725],{"href":1021},"Monitoring and logging Python APIs"," — see inside the container once it is running in production.",[174,2728,2729,2732],{},[18,2730,2731],{"href":1361},"Testing Python APIs with pytest"," — run integration tests against the same compose stack you ship.",[14,2734,2735],{},[177,2736,2737],{},"Other tracks:",[171,2739,2740,2746],{},[174,2741,2742,2745],{},[18,2743,2744],{"href":1324},"Deploying APIs to Render or Vercel"," — take the image to a real host.",[174,2747,2748,2751],{},[18,2749,2750],{"href":33},"Zero-downtime deploys for Python APIs"," — build on the graceful-shutdown work from Step 6.",[2753,2754,2755],"style",{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":255,"searchDepth":268,"depth":268,"links":2757},[2758,2759,2760,2764,2765,2766,2767,2768,2769,2770,2771,2772,2773,2774],{"id":165,"depth":268,"text":166},{"id":344,"depth":268,"text":345},{"id":457,"depth":268,"text":458,"children":2761},[2762,2763],{"id":894,"depth":279,"text":895},{"id":994,"depth":279,"text":995},{"id":1027,"depth":268,"text":1028},{"id":1210,"depth":268,"text":1211},{"id":1351,"depth":268,"text":1352},{"id":1849,"depth":268,"text":1850},{"id":1982,"depth":268,"text":1983},{"id":2070,"depth":268,"text":2071},{"id":2223,"depth":268,"text":2224},{"id":2325,"depth":268,"text":2326},{"id":2537,"depth":268,"text":2538},{"id":2624,"depth":268,"text":2625},{"id":2700,"depth":268,"text":2701},"Containerize FastAPI with Docker for reproducible production deploys: multi-stage builds, non-root users, gunicorn workers, healthchecks, signals, and env-based secrets.","md",{"pageTitle":2778,"datePublished":2779,"dateModified":2780},"Containerizing Python APIs with Docker: A Production Guide","2026-06-18","2026-07-23","\u002Fscaling-and-operating-production-python-apis\u002Fcontainerizing-python-apis-with-docker",{"title":5,"description":2775},"scaling-and-operating-production-python-apis\u002Fcontainerizing-python-apis-with-docker\u002Findex","Z9n0YzYRvdZuwhM0-hrg7cGju-yUKD6kscHSn951JGk",{"@context":2786,"@type":2787,"mainEntity":2788},"https:\u002F\u002Fschema.org","FAQPage",[2789,2794,2797,2800,2803],{"@type":2790,"name":2630,"acceptedAnswer":2791},"Question",{"@type":2792,"text":2793},"Answer","The container itself is cheap: a single small instance (1 vCPU, ~1GB RAM) running a slim image handles 1M requests a month — roughly 0.4 requests a second averaged out, with headroom for peaks — for a few dollars of compute. The real costs sit around it: managed Postgres, managed Redis, egress, and registry storage. A bloated 1.2GB image inflates the last two and slows autoscaling, so keeping the image at ~180MB protects your margin as you grow. Match WEB_CONCURRENCY to vCPUs and you rarely need a bigger box before you are well past a million requests.",{"@type":2790,"name":2639,"acceptedAnswer":2795},{"@type":2792,"text":2796},"Either works. uv is dramatically faster and produces deterministic installs from a lockfile, which pays off most in CI where you rebuild often and pay per build-minute. If your project already uses requirements.txt and you don't want to migrate, pip install --no-cache-dir -r requirements.txt in the builder stage is perfectly fine. The multi-stage pattern and layer ordering matter far more to your cost and speed than the installer choice.",{"@type":2790,"name":2655,"acceptedAnswer":2798},{"@type":2792,"text":2799},"Run migrations as a separate step before the new image takes traffic — a one-off docker run your-api alembic upgrade head job or a release\u002Fpre-deploy hook — never inside your CMD. Running migrations in the app entrypoint races when you scale to multiple replicas and can corrupt schema state. Keep migrations backward-compatible with the currently-running version so the old and new images can briefly coexist during a rolling deploy.",{"@type":2790,"name":2668,"acceptedAnswer":2801},{"@type":2792,"text":2802},"No. Anything in the Dockerfile, an ARG, or copied into the image is permanently in a layer and recoverable with docker history or a registry pull. Inject every secret at runtime via environment variables or a secret manager. This keeps the same image safe to reuse across staging and production and safe to push to a shared registry — a discipline that also makes key rotation a config change rather than an image rebuild.",{"@type":2790,"name":2680,"acceptedAnswer":2804},{"@type":2792,"text":2805},"That's the PID 1 signal problem. Your process isn't receiving SIGTERM because a shell is PID 1 and swallowing it. Use the exec form so your server is PID 1, or add init: true in compose \u002F --init on docker run to get a proper init that forwards signals. Until you fix this, every deploy drops in-flight requests and takes the maximum grace period to complete. ---",1784887027769]