{"id":"l-dk-2-2","courseId":"docker-kubernetes","lang":"en","kind":"lesson","summary":"Choose a base image for notes-api by weighing slim, Alpine and distroless, run the app as a non-root user, and lock the container down further at run time.","html":"<p>In 2024 a Skylane image failed a customer’s security review with 140 findings. Fewer than five were in code we wrote or called. The rest were in packages the base image happened to include: an XML library, an image converter, a mail transfer agent. None of them ran, and all of them were our problem. Your base image is a dependency you inherit wholesale, so choose it deliberately.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Choosing a base</h2>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"Scrollable table\" tabindex=\"0\"><table><thead>\n<tr>\n<th>Base</th>\n<th>What’s inside</th>\n<th>Good for</th>\n<th>Watch out for</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code dir=\"ltr\">node:24</code></td>\n<td>Full Debian, compilers, many libraries</td>\n<td>Build stages that compile native addons</td>\n<td>Large; lots to scan</td>\n</tr>\n<tr>\n<td><code dir=\"ltr\">node:24-slim</code></td>\n<td>Minimal Debian + Node</td>\n<td>Most runtime images</td>\n<td>Still has a shell and <code dir=\"ltr\">apt</code></td>\n</tr>\n<tr>\n<td><code dir=\"ltr\">node:24-alpine</code></td>\n<td>Alpine Linux (musl) + Node</td>\n<td>Very small images</td>\n<td>musl vs glibc differences</td>\n</tr>\n<tr>\n<td><code dir=\"ltr\">gcr.io/distroless/nodejs24-debian13</code></td>\n<td>Node and its runtime libraries only</td>\n<td>Hardened production images</td>\n<td>No shell, no package manager</td>\n</tr>\n</tbody>\n</table></div><p>My default for a team new to containers is <strong>slim</strong>: small, glibc-based so prebuilt native modules work, and you can still <code dir=\"ltr\">docker exec</code> into it with a shell when something’s on fire.</p>\n<p><strong>Alpine</strong> gets you smaller images, but it uses the musl C library instead of glibc. Prebuilt native addons compiled for glibc fail to load, and subtle differences in DNS resolution and memory allocation have bitten teams in production. Choose it when you’ve tested your full dependency tree on it, not because the number on the tag page is lower.</p>\n<p><strong>Distroless</strong> images contain Node.js and the libraries it needs, and nothing else: no shell, no <code dir=\"ltr\">apt</code>, no <code dir=\"ltr\">curl</code>. An attacker who gets code execution finds very little to work with, and the scanner has very little to report. The cost is debuggability. You can’t <code dir=\"ltr\">exec</code> a shell into it, and you’ll learn the Kubernetes way around that, <code dir=\"ltr\">kubectl debug</code>, in the last lesson of this course.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">Stop running as root</h2>\n<p>By default, the process in a container runs as root, UID 0. Namespaces limit what that root can see, but it’s still root as far as the kernel is concerned. Combined with a kernel bug or a misconfigured mount, that is how a container compromise becomes a host compromise. Running as an unprivileged user turns many of those bugs into dead ends.</p>\n<p>The official Node.js images ship a <code dir=\"ltr\">node</code> user with UID 1000. Switch to it in the runtime stage, after anything that needs root:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"dockerfile\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Dockerfile</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\"># …build and deps stages from the previous lesson</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> node:24-slim </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> runtime</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ENV</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> NODE_ENV=production</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WORKDIR</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /app</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">COPY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> package.json ./</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">COPY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> --from=deps /app/node_modules ./node_modules</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">COPY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> --from=build /app/dist ./dist</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">USER</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> node</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">EXPOSE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> 3000</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">CMD</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"node\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"dist/server.js\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]</span></span></code></pre></div>\n<p>Note what’s <em>not</em> there: no <code dir=\"ltr\">--chown=node:node</code> on the copies. The files stay owned by root and readable by everyone, so the app can read its code but can’t change it. If an attacker finds a remote-code-execution bug, they can’t rewrite <code dir=\"ltr\">dist/server.js</code> to persist. Give the app write access only where it genuinely needs to write, and prefer <code dir=\"ltr\">/tmp</code> or a mounted volume for that.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Fixing a permission error with USER root</span></p>\n<p>The app needs to write a temp file, gets <code dir=\"ltr\">EACCES</code>, and someone adds <code dir=\"ltr\">USER root</code> at the end of the Dockerfile “for now”. It ships, and stays. Fix the actual need instead: create the one directory the app writes to and hand it to the app user, or point the app at <code dir=\"ltr\">/tmp</code>.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"dockerfile\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">Dockerfile</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">RUN</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> mkdir -p /app/tmp &#x26;&#x26; chown node:node /app/tmp</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">USER</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> node</span></span></code></pre></div>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">The distroless version</h2>\n<p>Here’s the same runtime stage on distroless. The <code dir=\"ltr\">:nonroot</code> tag runs as UID 65532 out of the box, and the image’s entrypoint is already <code dir=\"ltr\">node</code>, so <code dir=\"ltr\">CMD</code> lists only the script:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"dockerfile\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">Dockerfile</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> gcr.io/distroless/nodejs24-debian13:nonroot </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> runtime</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ENV</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> NODE_ENV=production</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WORKDIR</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /app</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">COPY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> package.json ./</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">COPY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> --from=deps /app/node_modules ./node_modules</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">COPY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> --from=build /app/dist ./dist</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">EXPOSE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> 3000</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">CMD</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"dist/server.js\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]</span></span></code></pre></div>\n<p>The build and deps stages still use <code dir=\"ltr\">node:24-slim</code>; only the shipped stage changes. Keep the Node major version the same across stages; otherwise native modules compiled against one Node version’s ABI end up loading in another and fail at startup.</p>\n<p>You can confirm who the container will run as without starting it:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"bash\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">Terminal</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">docker</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> image</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> inspect</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> notes-api:1.0.0</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> --format</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '{{.Config.User}}'</span></span></code></pre></div>\n<h2 id=\"sec-4\" tabindex=\"-1\">Lock it down at run time</h2>\n<p>The image sets defaults; the runtime can tighten them further:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"bash\" data-index=\"4\"><div class=\"code-head\"><span class=\"code-title\">Terminal</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">docker</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> run</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> --rm</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> -p</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 8080:3000</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --read-only</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> --tmpfs</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> /tmp</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --cap-drop</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> ALL</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --security-opt</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> no-new-privileges</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">  notes-api:1.0.0</span></span></code></pre></div>\n<p><code dir=\"ltr\">--read-only</code> makes the container’s root filesystem immutable, with <code dir=\"ltr\">/tmp</code> as a small writable in-memory mount. <code dir=\"ltr\">--cap-drop ALL</code> removes the Linux capabilities that root-ish operations need. <code dir=\"ltr\">no-new-privileges</code> stops a process from gaining privileges through setuid binaries. In section 5 you’ll set the same things in a Kubernetes <code dir=\"ltr\">securityContext</code>, where they become policy for every Pod.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Ports below 1024</span></p>\n<p>Keep apps on high ports like 3000 or 8080 inside the container. You can still publish them on 80 or 443 at the edge; the mapping happens outside the process, so the app never needs privileges for it.</p>\n</aside>\n<p>Your image is now small and unprivileged. Next: how to name it so that what you deploy is exactly what you tested.</p>\n","toc":[{"id":"sec-1","text":"Choosing a base","level":2},{"id":"sec-2","text":"Stop running as root","level":2},{"id":"sec-3","text":"The distroless version","level":2},{"id":"sec-4","text":"Lock it down at run time","level":2}],"takeaways":["Every package in the base image is something you patch, scan and defend, so start from the smallest base your app really runs on.","<code dir=\"ltr\">node:24-slim</code> is a solid default; Alpine uses musl instead of glibc, and distroless removes the shell and package manager entirely.","Official Node.js images include a <code dir=\"ltr\">node</code> user (UID 1000); switch to it with <code dir=\"ltr\">USER node</code> after the steps that need root.","Leave application files owned by root and read-only to the app user, so a compromised process can’t rewrite its own code.","Add runtime hardening such as <code dir=\"ltr\">--read-only</code>, <code dir=\"ltr\">--cap-drop ALL</code> and <code dir=\"ltr\">no-new-privileges</code>; Kubernetes has the same controls in <code dir=\"ltr\">securityContext</code>."],"further":[{"title":"Dockerfile reference: USER","url":"https://docs.docker.com/reference/dockerfile/#user"},{"title":"Docker Engine security","url":"https://docs.docker.com/engine/security/"},{"title":"Building best practices","url":"https://docs.docker.com/build/building/best-practices/"}],"quiz":[{"q":"<p>notes-api depends on a package with a native addon. After switching the base from <code dir=\"ltr\">node:24-slim</code> to <code dir=\"ltr\">node:24-alpine</code>, the container crashes on start with an error loading a <code dir=\"ltr\">.node</code> file. What’s the likely cause?</p>","options":[{"text":"Alpine images don’t support Node.js 24.","why":"Official <code dir=\"ltr\">node:24-alpine</code> images exist and run Node 24 fine. The issue is the C library, not Node’s version."},{"text":"The image needs <code dir=\"ltr\">EXPOSE</code> for native modules to load.","why":"<code dir=\"ltr\">EXPOSE</code> is documentation about ports. It has nothing to do with loading shared libraries."},{"text":"Alpine runs as non-root by default, which blocks native modules.","why":"Alpine-based Node images run as root by default, like the Debian ones. Permissions aren’t the problem here."},{"text":"The addon was compiled against glibc, and Alpine uses musl, so the binary can’t load.","why":"Correct. Prebuilt binaries often target glibc. Rebuild the addon inside an Alpine build stage or stay on a glibc base like slim."}],"answer":3},{"q":"<p>Which change makes notes-api run as a non-root user in the official Node.js image?</p>","options":[{"text":"Add <code dir=\"ltr\">USER node</code> in the runtime stage after the steps that need root.","why":"Correct. The official image creates <code dir=\"ltr\">node</code> (UID 1000). Instructions and the container process after <code dir=\"ltr\">USER</code> run as that user."},{"text":"Add <code dir=\"ltr\">RUN su node</code> before <code dir=\"ltr\">CMD</code>.","why":"<code dir=\"ltr\">su</code> in a <code dir=\"ltr\">RUN</code> step only affects that one build step’s shell. The container still starts as root."},{"text":"Add <code dir=\"ltr\">ENV USER=node</code>.","why":"That sets an environment variable named <code dir=\"ltr\">USER</code>. It doesn’t change which user the process runs as."},{"text":"Publish the container on a port above 1024.","why":"The port number doesn’t change the user. The container would still run as root."}],"answer":0},{"q":"<p>You move notes-api to <code dir=\"ltr\">gcr.io/distroless/nodejs24-debian13:nonroot</code>. Which <code dir=\"ltr\">CMD</code> is correct?</p>","options":[{"text":"<code dir=\"ltr\">CMD [&quot;node&quot;, &quot;dist/server.js&quot;]</code>","why":"The distroless Node image’s entrypoint is already <code dir=\"ltr\">node</code>, so this would run <code dir=\"ltr\">node node dist/server.js</code> and fail to find a module named <code dir=\"ltr\">node</code>."},{"text":"<code dir=\"ltr\">CMD node dist/server.js</code>","why":"Shell form needs <code dir=\"ltr\">/bin/sh</code>, and distroless images have no shell, so the container can’t start."},{"text":"<code dir=\"ltr\">CMD [&quot;dist/server.js&quot;]</code>","why":"Correct. The entrypoint is <code dir=\"ltr\">node</code>, so <code dir=\"ltr\">CMD</code> supplies the script path as its argument."},{"text":"<code dir=\"ltr\">CMD [&quot;npm&quot;, &quot;start&quot;]</code>","why":"Distroless Node images ship the Node runtime, not npm. And you’d want to avoid npm as PID 1 anyway."}],"answer":2},{"q":"<p>Why leave notes-api’s <code dir=\"ltr\">dist/</code> files owned by root even though the process runs as <code dir=\"ltr\">node</code>?</p>","options":[{"text":"Node.js refuses to execute files owned by the current user.","why":"Node happily runs files you own. This is about limiting damage, not a runtime rule."},{"text":"If an attacker gets code execution as <code dir=\"ltr\">node</code>, they can read the code but can’t modify it or plant files in it.","why":"Correct. Read-only for the app user shrinks what an exploit can change. Give write access only to directories the app must write."},{"text":"Root-owned files are smaller in the image.","why":"File ownership is a few bytes of metadata either way."},{"text":"Kubernetes rejects images whose files are owned by a non-root user.","why":"Kubernetes has no such rule. It can require the <em>process</em> to be non-root, which you’re already doing."}],"answer":1}],"exercise":{"kind":"spot-bug","language":"dockerfile","code":"# syntax=docker/dockerfile:1\n# (build and deps stages as in the previous lesson)\nFROM node:24-slim AS runtime\nENV NODE_ENV=production\nWORKDIR /app\nCOPY package.json ./\nCOPY --from=deps /app/node_modules ./node_modules\nCOPY --from=build /app/dist ./dist\nUSER node\n# temporary: fixes EACCES writing /app/tmp\nUSER root\nEXPOSE 3000\nCMD [\"node\", \"dist/server.js\"]","bugLines":[11],"title":"The temporary fix that shipped","prompt":"<p>A security review flags this image: “container process runs as UID 0”. The author insists they switched to the <code dir=\"ltr\">node</code> user. Select the line that makes the container run as root, and think about what the right fix for the original error would be.</p>\n","hints":["The last <code dir=\"ltr\">USER</code> instruction before <code dir=\"ltr\">CMD</code> decides who the container runs as.","The real need was one writable directory, not root for the whole process."],"explanation":"<p>Line 11 switches back to root after <code dir=\"ltr\">USER node</code>, so the process starts as UID 0. The original <code dir=\"ltr\">EACCES</code> came from <code dir=\"ltr\">/app</code> being owned by root. Create the one directory the app writes to, hand it to the app user, and stay non-root:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"dockerfile\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Dockerfile</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">RUN</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> mkdir -p /app/tmp &#x26;&#x26; chown node:node /app/tmp</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">USER</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> node</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">EXPOSE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> 3000</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">CMD</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"node\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"dist/server.js\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]</span></span></code></pre></div>\n<p>Even better, have the app write to <code dir=\"ltr\">/tmp</code>, which is writable by every user and can be a <code dir=\"ltr\">tmpfs</code> mount when the root filesystem is read-only.</p>\n","checkLabels":{}},"words":737,"readMinutes":4}