{"id":492,"date":"2026-07-22T13:01:41","date_gmt":"2026-07-22T13:01:41","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/docker-container-security-the-checklist-i-actually-run\/"},"modified":"2026-07-22T13:01:41","modified_gmt":"2026-07-22T13:01:41","slug":"docker-container-security-the-checklist-i-actually-run","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/docker-container-security-the-checklist-i-actually-run\/","title":{"rendered":"Docker Container Security: The Checklist I Actually Run"},"content":{"rendered":"<p>Short version for the impatient: your container is probably running as root, it probably has more Linux capabilities than it needs, and its filesystem is probably writable. Three small changes fix most of that. If you want to know why I care, read on.<\/p>\n<p>I got a scare about eight months ago. Not a breach, thankfully. A client asked for a security questionnaire to be filled in and one of the questions was &ldquo;do application containers run as a non-root user?&rdquo; I said yes, because obviously they did, and then I went and checked, and they did not. Every service. Every image. Root all the way down, for about two years, because nobody had ever typed <code>USER<\/code> into a Dockerfile and Docker doesn&rsquo;t nag you about it.<\/p>\n<p>That&rsquo;s the annoying thing about container security. Almost none of it is hard. It&rsquo;s just invisible by default. Docker will happily run whatever you give it with the most permissive settings available and never mention that there was a choice.<\/p>\n<p>So here&rsquo;s the checklist I run now, in the order I run it.<\/p>\n<h2 id=\"containers-arent-a-security-boundary-and-i-wish-people-said-that-louder\">Containers aren&rsquo;t a security boundary, and I wish people said that louder<\/h2>\n<p>The mental model a lot of us start with is &ldquo;container equals tiny VM&rdquo;. It isn&rsquo;t. Your container shares the host kernel. A VM has a hypervisor between the guest and the hardware. A container has namespaces and cgroups, which are kernel features doing bookkeeping, not isolation in the hardware sense.<\/p>\n<p>Practically: a kernel bug that lets a process escalate inside the container can put that process on the host. That&rsquo;s a much shorter path than escaping a hypervisor. It doesn&rsquo;t mean containers are unsafe. It means the defence has to be &ldquo;this process can&rsquo;t do much even if it gets out&rdquo;, not &ldquo;this process can&rsquo;t get out&rdquo;.<\/p>\n<p>Everything below follows from that one idea.<\/p>\n<h2 id=\"running-as-root-is-the-default-and-its-a-two-line-fix\">Running as root is the default and it&rsquo;s a two-line fix<\/h2>\n<p>By default, the process inside your container runs as uid 0. If you&rsquo;ve bind-mounted a host directory into that container, uid 0 inside is uid 0 outside. Files it writes are owned by root on your host. That&rsquo;s not theoretical; I&rsquo;ve spent real time running <code>sudo chown<\/code> on directories a container mangled.<\/p>\n<p>The fix is boring:<\/p>\n<pre><code class=\"language-dockerfile\">FROM node:22-slim\n\nWORKDIR \/app\nCOPY package*.json .\/\nRUN npm ci --omit=dev\nCOPY . .\n\nUSER node\nCMD [&quot;node&quot;, &quot;server.js&quot;]\n<\/code><\/pre>\n<p>The official Node images already ship a <code>node<\/code> user, so you don&rsquo;t even have to create one. If your base image doesn&rsquo;t, add it yourself:<\/p>\n<pre><code class=\"language-dockerfile\">RUN groupadd --system --gid 1001 app \\\n &amp;&amp; useradd --system --uid 1001 --gid app app\nUSER app\n<\/code><\/pre>\n<p>Order matters. Anything that needs root, like installing system packages, has to happen before the <code>USER<\/code> line. That trips people up and then they give up and delete the line. Don&rsquo;t. Just move it down.<\/p>\n<p>If you want the stronger version of this, Docker supports <a href=\"https:\/\/docs.docker.com\/engine\/security\/rootless\/\" rel=\"nofollow noopener\" target=\"_blank\">rootless mode<\/a>, where the daemon itself runs as an unprivileged user. I run it on my own machine. On shared build servers it&rsquo;s been more of a fight, mostly around networking and storage drivers, so I won&rsquo;t pretend it&rsquo;s free.<\/p>\n<h2 id=\"drop-every-capability-add-back-what-breaks\">Drop every capability, add back what breaks<\/h2>\n<p>Linux capabilities split root&rsquo;s powers into pieces: bind to low ports, change file ownership, load kernel modules, and so on. Docker grants a default set to every container. Your Express app does not need <code>CAP_CHOWN<\/code>.<\/p>\n<p>Start from nothing:<\/p>\n<pre><code class=\"language-yaml\">services:\n  api:\n    image: ghcr.io\/me\/api:sha-9f2c1ab\n    cap_drop:\n      - ALL\n    security_opt:\n      - no-new-privileges:true\n<\/code><\/pre>\n<p>Then run your test suite and see what explodes. Usually nothing does. If you need port 80 directly, you&rsquo;ll want <code>NET_BIND_SERVICE<\/code> back, though I&rsquo;d rather listen on 8080 and let the reverse proxy handle 80.<\/p>\n<p><code>no-new-privileges<\/code> is the one people skip. It stops a process inside the container from gaining privileges through setuid binaries. There&rsquo;s basically no downside for an application container, and it&rsquo;s one line.<\/p>\n<h2 id=\"make-the-filesystem-read-only\">Make the filesystem read-only<\/h2>\n<p>If your app doesn&rsquo;t write to disk, don&rsquo;t let it. If it does write, let it write to exactly one place.<\/p>\n<pre><code class=\"language-yaml\">services:\n  api:\n    read_only: true\n    tmpfs:\n      - \/tmp\n    volumes:\n      - uploads:\/var\/app\/uploads\n<\/code><\/pre>\n<p>This one catches things. An attacker who lands remote code execution but can&rsquo;t drop a file anywhere persistent is having a much worse day. It also surfaces sloppiness in your own code, because you find out what&rsquo;s quietly scribbling into the image at runtime.<\/p>\n<p>Expect a couple of surprises the first time. Some frameworks cache compiled templates or config into the app directory on boot. I hit this with a PHP service that wanted to write its container cache at runtime, and the fix was to warm the cache at build time instead, which was better anyway. I wrote about the queue side of that same app in <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-horizon-in-production-the-queue-setup-i-stopped-babysitting\/\" rel=\"noopener\">my Laravel Horizon setup<\/a>.<\/p>\n<h2 id=\"build-secrets-do-not-belong-in-arg-or-env\">Build secrets do not belong in ARG or ENV<\/h2>\n<p>This is the mistake I see most in other people&rsquo;s repos, and it&rsquo;s the one with the worst blast radius.<\/p>\n<p>Wrong:<\/p>\n<pre><code class=\"language-dockerfile\">ARG NPM_TOKEN\nRUN echo &quot;\/\/registry.npmjs.org\/:_authToken=${NPM_TOKEN}&quot; &gt; .npmrc \\\n &amp;&amp; npm ci\n<\/code><\/pre>\n<p>That token is now baked into a layer. Deleting the file in a later layer doesn&rsquo;t remove it. Anyone who can pull the image can run <code>docker history<\/code> or just unpack the tarball and read it.<\/p>\n<p>Right, using BuildKit&rsquo;s <a href=\"https:\/\/docs.docker.com\/build\/building\/secrets\/\" rel=\"nofollow noopener\" target=\"_blank\">secret mounts<\/a>:<\/p>\n<pre><code class=\"language-dockerfile\">RUN --mount=type=secret,id=npmrc,target=\/root\/.npmrc \\\n    npm ci --omit=dev\n<\/code><\/pre>\n<pre><code class=\"language-bash\">docker build --secret id=npmrc,src=$HOME\/.npmrc -t api:latest .\n<\/code><\/pre>\n<p>The secret is available during that one command and never written to a layer. Runtime secrets are a separate problem: keep them out of <code>ENV<\/code> too, because <code>docker inspect<\/code> prints environment variables in full, and so do a lot of logging agents.<\/p>\n<h2 id=\"never-mount-the-docker-socket-into-a-container\">Never mount the Docker socket into a container<\/h2>\n<p><code>-v \/var\/run\/docker.sock:\/var\/run\/docker.sock<\/code> is root on the host. Not &ldquo;close to root&rdquo;. Root. A process with the socket can start a new container with the host filesystem mounted at <code>\/host<\/code> and do whatever it wants.<\/p>\n<p>Plenty of CI tools and monitoring agents ask for it. Sometimes there&rsquo;s no alternative and you accept the risk knowingly, on a box that does nothing else. What you shouldn&rsquo;t do is mount it into an app container next to your customer data because a tutorial said to.<\/p>\n<p>The <a href=\"https:\/\/cheatsheetseries.owasp.org\/cheatsheets\/Docker_Security_Cheat_Sheet.html\" rel=\"nofollow noopener\" target=\"_blank\">OWASP Docker Security Cheat Sheet<\/a> is good on this and worth twenty minutes if you want the fuller list.<\/p>\n<h2 id=\"pin-by-digest-then-scan\">Pin by digest, then scan<\/h2>\n<p>Scanning gets all the attention because vendors sell scanners. Pinning matters more and costs nothing.<\/p>\n<p><code>FROM node:22-slim<\/code> is a moving target. The image behind that tag changes. Your reproducible build isn&rsquo;t reproducible, and &ldquo;it worked in CI last week&rdquo; stops being evidence of anything.<\/p>\n<pre><code class=\"language-dockerfile\">FROM node:22-slim@sha256:c0e8f2ff2b3f4d7dc1c6ea0d9e0d3f5b1a5f2f0d3d9b8e1a4c7d2e5f8a1b4c7d\n<\/code><\/pre>\n<p>Yes, it&rsquo;s ugly. Yes, you need Renovate or Dependabot to bump it. That&rsquo;s the point: upgrades become a reviewed change instead of something that happens to you at 3am.<\/p>\n<p>Then scan. <a href=\"https:\/\/github.com\/aquasecurity\/trivy\" rel=\"nofollow noopener\" target=\"_blank\">Trivy<\/a> is what I use, mostly because it runs in CI without an account:<\/p>\n<pre><code class=\"language-bash\">trivy image --severity HIGH,CRITICAL --exit-code 1 api:latest\n<\/code><\/pre>\n<p>One warning from experience: don&rsquo;t gate your pipeline on zero findings on day one. You&rsquo;ll get four hundred CVEs in your base image, half of them unreachable from your code, and within a week someone adds <code>--exit-code 0<\/code> and nobody looks again. Start by failing on CRITICAL only, get that to zero, then tighten.<\/p>\n<p>Smaller images help here more than any scanner does. A multi-stage build that ships only the compiled binary and its runtime has almost no packages to have CVEs about. That&rsquo;s a real chunk of why I moved some services onto leaner runtimes, which I got into in <a href=\"https:\/\/abrarqasim.com\/blog\/bun-runtime-in-production-a-year-of-replacing-node\/\" rel=\"noopener\">a year of running Bun in production<\/a>.<\/p>\n<h2 id=\"what-id-actually-do-this-week\">What I&rsquo;d actually do this week<\/h2>\n<p>Pick your busiest service. Open its Dockerfile and its compose file. Add <code>USER<\/code>, add <code>cap_drop: ALL<\/code>, add <code>no-new-privileges<\/code>, add <code>read_only: true<\/code> with a tmpfs for <code>\/tmp<\/code>. Run the test suite. Fix what breaks, which will be less than you think.<\/p>\n<p>That&rsquo;s an hour of work and it removes most of the easy paths off a compromised container. Digest pinning and scanning can wait until next week; they&rsquo;re maintenance, and maintenance without the basics in place is just paperwork.<\/p>\n<p>I do this kind of hardening on client infrastructure fairly often, and it&rsquo;s usually the same four lines missing every time. If that&rsquo;s the sort of thing you need a hand with, it&rsquo;s <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">what I spend most of my time on<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The Docker container security checklist I run on every service: non-root users, dropped capabilities, read-only filesystems, build secrets and digest pinning.<\/p>\n","protected":false},"author":2,"featured_media":491,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"The Docker container security checklist I run on every service: non-root users, dropped capabilities, read-only filesystems, build secrets and digest pinning.","rank_math_focus_keyword":"docker container security best practices","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302,151],"tags":[561,80,125,562,126,349,564,563],"class_list":["post-492","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-security","tag-container-security","tag-devops","tag-docker","tag-docker-compose-2","tag-dockerfile","tag-infrastructure","tag-rootless-docker","tag-trivy"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/492","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=492"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/492\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/491"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=492"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=492"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=492"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}