[{"data":1,"prerenderedAt":609},["ShallowReactive",2],{"mdc-9epn78-key":3,"mdc-2r5f4e-key":16,"mdc--35ul1f-key":56,"mdc-g4pbb9-key":189,"mdc--vwtxw7-key":201,"mdc--r5wksm-key":308,"mdc-kfcuy-key":320,"mdc-ncu4kd-key":398,"mdc-rq6z3o-key":408,"mdc-3e3qcy-key":445},{"data":4,"body":5},{},{"type":6,"children":7},"root",[8],{"type":9,"tag":10,"props":11,"children":12},"element","p",{},[13],{"type":14,"value":15},"text","There are two moments a credential can leak into an image: while you build it, and while you run it. They have different mechanisms and different fixes, and most guidance covers one and leaves the other.",{"data":17,"body":18},{},{"type":6,"children":19},[20,27,38,51],{"type":9,"tag":21,"props":22,"children":24},"h2",{"id":23},"the-rule",[25],{"type":14,"value":26},"The rule",{"type":9,"tag":10,"props":28,"children":29},{},[30,36],{"type":9,"tag":31,"props":32,"children":33},"strong",{},[34],{"type":14,"value":35},"An image is a public artifact.",{"type":14,"value":37}," Not because you published it, but because everyone who can pull it can read every byte of every layer, plus the metadata. Anything in the image is available to anyone who has the image.",{"type":9,"tag":39,"props":40,"children":44},"pre",{"className":41,"code":43,"language":14},[42],"language-text","docker save myapp:1.0 | tar -xO | grep -r \"sk_live\"\ndocker history --no-trunc myapp:1.0\n",[45],{"type":9,"tag":46,"props":47,"children":49},"code",{"__ignoreMap":48},"",[50],{"type":14,"value":43},{"type":9,"tag":10,"props":52,"children":53},{},[54],{"type":14,"value":55},"Neither of those is a hack. They are supported commands doing what they say.",{"data":57,"body":58},{},{"type":6,"children":59},[60,66,90,101,132,155],{"type":9,"tag":21,"props":61,"children":63},{"id":62},"how-secrets-get-into-images",[64],{"type":14,"value":65},"How secrets get into images",{"type":9,"tag":10,"props":67,"children":68},{},[69,80,82,88],{"type":9,"tag":31,"props":70,"children":71},{},[72,78],{"type":9,"tag":46,"props":73,"children":75},{"className":74},[],[76],{"type":14,"value":77},"ARG",{"type":14,"value":79},".",{"type":14,"value":81}," Build arguments are recorded in the image's metadata and printed by ",{"type":9,"tag":46,"props":83,"children":85},{"className":84},[],[86],{"type":14,"value":87},"docker history --no-trunc",{"type":14,"value":89},". Deleting the file the token was used for does not remove the argument.",{"type":9,"tag":39,"props":91,"children":96},{"className":92,"code":94,"language":95,"meta":48},[93],"language-dockerfile","ARG NPM_TOKEN                 # visible in docker history, forever\nRUN npm ci\n","dockerfile",[97],{"type":9,"tag":46,"props":98,"children":99},{"__ignoreMap":48},[100],{"type":14,"value":94},{"type":9,"tag":10,"props":102,"children":103},{},[104,114,116,122,124,130],{"type":9,"tag":31,"props":105,"children":106},{},[107,113],{"type":9,"tag":46,"props":108,"children":110},{"className":109},[],[111],{"type":14,"value":112},"ENV",{"type":14,"value":79},{"type":14,"value":115}," Worse — baked into the image ",{"type":9,"tag":117,"props":118,"children":119},"em",{},[120],{"type":14,"value":121},"and",{"type":14,"value":123}," injected into every container started from it, so ",{"type":9,"tag":46,"props":125,"children":127},{"className":126},[],[128],{"type":14,"value":129},"docker inspect",{"type":14,"value":131}," shows it and so does the process environment.",{"type":9,"tag":10,"props":133,"children":134},{},[135,145,147,153],{"type":9,"tag":31,"props":136,"children":137},{},[138,144],{"type":9,"tag":46,"props":139,"children":141},{"className":140},[],[142],{"type":14,"value":143},"COPY",{"type":14,"value":79},{"type":14,"value":146}," A ",{"type":9,"tag":46,"props":148,"children":150},{"className":149},[],[151],{"type":14,"value":152},".env",{"type":14,"value":154}," or a key copied in and deleted later is still in the earlier layer. Layers are append-only; the delete is a marker on top.",{"type":9,"tag":10,"props":156,"children":157},{},[158,171,173,179,181,187],{"type":9,"tag":31,"props":159,"children":160},{},[161,163,169],{"type":14,"value":162},"A ",{"type":9,"tag":46,"props":164,"children":166},{"className":165},[],[167],{"type":14,"value":168},"RUN",{"type":14,"value":170}," that writes.",{"type":14,"value":172}," ",{"type":9,"tag":46,"props":174,"children":176},{"className":175},[],[177],{"type":14,"value":178},"git clone https://$TOKEN@github.com/...",{"type":14,"value":180}," puts the token in ",{"type":9,"tag":46,"props":182,"children":184},{"className":183},[],[185],{"type":14,"value":186},".git/config",{"type":14,"value":188}," inside the layer, even if the variable came from a secret.",{"data":190,"body":191},{},{"type":6,"children":192},[193],{"type":9,"tag":194,"props":195,"children":200},"quiz",{":answer":196,":options":197,"explanation":198,"question":199},"0","[\"Yes — build arguments are stored in image metadata and shown by `docker history --no-trunc`\",\"No — it only existed during that instruction\",\"Only if the build failed\"]","The value is recorded with the layer that used it. Anyone who pulls the image can read it back. Treat any credential ever passed as `ARG` as compromised, and rotate it.","A Dockerfile does `ARG TOKEN`, uses it in one `RUN`, and never writes it to disk. Is the token in the published image?",[],{"data":202,"body":203},{},{"type":6,"children":204},[205,211,220,229,242,247,256,265,285,290,299],{"type":9,"tag":21,"props":206,"children":208},{"id":207},"build-secrets-done-properly",[209],{"type":14,"value":210},"Build secrets, done properly",{"type":9,"tag":39,"props":212,"children":215},{"className":213,"code":214,"language":95,"meta":48},[93],"# syntax=docker/dockerfile:1\nFROM node:22-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN --mount=type=secret,id=npmrc,target=/root/.npmrc \\\n    npm ci --omit=dev\n",[216],{"type":9,"tag":46,"props":217,"children":218},{"__ignoreMap":48},[219],{"type":14,"value":214},{"type":9,"tag":39,"props":221,"children":224},{"className":222,"code":223,"language":14},[42],"docker build --secret id=npmrc,src=$HOME/.npmrc -t app .\n",[225],{"type":9,"tag":46,"props":226,"children":227},{"__ignoreMap":48},[228],{"type":14,"value":223},{"type":9,"tag":10,"props":230,"children":231},{},[232,234,240],{"type":14,"value":233},"The file is a tmpfs mounted for that instruction only. Not a layer, not in ",{"type":9,"tag":46,"props":235,"children":237},{"className":236},[],[238],{"type":14,"value":239},"docker history",{"type":14,"value":241},", not in the image.",{"type":9,"tag":10,"props":243,"children":244},{},[245],{"type":14,"value":246},"Secrets can come from a file or straight from the environment, which is what CI wants:",{"type":9,"tag":39,"props":248,"children":251},{"className":249,"code":250,"language":14},[42],"docker build --secret id=token,env=GITHUB_TOKEN -t app .\n",[252],{"type":9,"tag":46,"props":253,"children":254},{"__ignoreMap":48},[255],{"type":14,"value":250},{"type":9,"tag":39,"props":257,"children":260},{"className":258,"code":259,"language":95,"meta":48},[93],"RUN --mount=type=secret,id=token \\\n    TOKEN=\"$(cat /run/secrets/token)\" && \\\n    curl -H \"Authorization: Bearer $TOKEN\" -fsSL https://internal/artifact.tar | tar -x\n",[261],{"type":9,"tag":46,"props":262,"children":263},{"__ignoreMap":48},[264],{"type":14,"value":259},{"type":9,"tag":10,"props":266,"children":267},{},[268,270,276,278,283],{"type":14,"value":269},"Unnamed targets land in ",{"type":9,"tag":46,"props":271,"children":273},{"className":272},[],[274],{"type":14,"value":275},"/run/secrets/\u003Cid>",{"type":14,"value":277},". Note that the value is still only in the shell's memory for that one ",{"type":9,"tag":46,"props":279,"children":281},{"className":280},[],[282],{"type":14,"value":168},{"type":14,"value":284}," — nothing writes it down.",{"type":9,"tag":10,"props":286,"children":287},{},[288],{"type":14,"value":289},"And for Git over SSH, the agent form, so the key itself never enters the build:",{"type":9,"tag":39,"props":291,"children":294},{"className":292,"code":293,"language":95,"meta":48},[93],"RUN --mount=type=ssh git clone git@github.com:acme/private.git\n",[295],{"type":9,"tag":46,"props":296,"children":297},{"__ignoreMap":48},[298],{"type":14,"value":293},{"type":9,"tag":39,"props":300,"children":303},{"className":301,"code":302,"language":14},[42],"docker build --ssh default -t app .\n",[304],{"type":9,"tag":46,"props":305,"children":306},{"__ignoreMap":48},[307],{"type":14,"value":302},{"data":309,"body":310},{},{"type":6,"children":311},[312],{"type":9,"tag":313,"props":314,"children":319},"fill-blank",{":answer":315,"hint":316,"placeholder":317,"prompt":318},"[\"docker build --secret id=npmtoken,env=NPM_TOKEN .\",\"docker build --secret id=npmtoken,env=NPM_TOKEN -t app .\",\"docker buildx build --secret id=npmtoken,env=NPM_TOKEN .\"]","One flag, then `id=` and `env=` separated by a comma.","docker build --secret ...","Build the current directory, passing the environment variable `NPM_TOKEN` as a build secret with the id `npmtoken`.",[],{"data":321,"body":322},{},{"type":6,"children":323},[324,330,342,347,356,366,377],{"type":9,"tag":21,"props":325,"children":327},{"id":326},"runtime-configuration",[328],{"type":14,"value":329},"Runtime configuration",{"type":9,"tag":10,"props":331,"children":332},{},[333,335,340],{"type":14,"value":334},"At run time the trade-offs change, because nothing is being baked into a distributable artifact. But ",{"type":9,"tag":46,"props":336,"children":338},{"className":337},[],[339],{"type":14,"value":129},{"type":14,"value":341}," still shows every environment variable to anyone who can reach the daemon, and so does the API, and so do most container platforms' dashboards.",{"type":9,"tag":10,"props":343,"children":344},{},[345],{"type":14,"value":346},"The ladder, worst to best:",{"type":9,"tag":39,"props":348,"children":351},{"className":349,"code":350,"language":14},[42],"-e DATABASE_PASSWORD=hunter2      # in shell history, ps output, and inspect\n--env-file .env                   # better: not in history. Still in inspect.\nCompose secrets                   # mounted as a file, not an environment variable\nAn external secret manager        # fetched at start, never on disk\n",[352],{"type":9,"tag":46,"props":353,"children":354},{"__ignoreMap":48},[355],{"type":14,"value":350},{"type":9,"tag":10,"props":357,"children":358},{},[359,364],{"type":9,"tag":31,"props":360,"children":361},{},[362],{"type":14,"value":363},"Compose secrets",{"type":14,"value":365}," mount a file into the container rather than setting a variable:",{"type":9,"tag":39,"props":367,"children":372},{"className":368,"code":370,"language":371,"meta":48},[369],"language-yaml","services:\n  db:\n    image: postgres:17-alpine\n    environment:\n      POSTGRES_PASSWORD_FILE: /run/secrets/db_password\n    secrets:\n      - db_password\n\nsecrets:\n  db_password:\n    file: ./secrets/db_password.txt\n","yaml",[373],{"type":9,"tag":46,"props":374,"children":375},{"__ignoreMap":48},[376],{"type":14,"value":370},{"type":9,"tag":10,"props":378,"children":379},{},[380,382,388,390,396],{"type":14,"value":381},"The ",{"type":9,"tag":46,"props":383,"children":385},{"className":384},[],[386],{"type":14,"value":387},"_FILE",{"type":14,"value":389}," suffix convention is supported by most official images — Postgres, MySQL, Redis and others will read a secret from a file when given ",{"type":9,"tag":46,"props":391,"children":393},{"className":392},[],[394],{"type":14,"value":395},"*_FILE",{"type":14,"value":397},". That keeps the value out of the environment entirely.",{"data":399,"body":400},{},{"type":6,"children":401},[402],{"type":9,"tag":194,"props":403,"children":407},{":answer":196,":options":404,"explanation":405,"question":406},"[\"It is mounted as a file, so it does not appear in `docker inspect` or the container's environment\",\"It is encrypted at rest by Docker\",\"Environment variables have a length limit\"]","Local Compose secrets are not encrypted — the improvement is the exposure surface. A file at `/run/secrets/x` is readable by the process that needs it and is not enumerated by inspect, the API, `/proc/\u003Cpid>/environ`, or a crash reporter dumping the environment.","Why is a Compose `secret` better than an environment variable for a database password?",[],{"data":409,"body":410},{},{"type":6,"children":411},[412,418,431,440],{"type":9,"tag":21,"props":413,"children":415},{"id":414},"configuration-that-is-not-secret",[416],{"type":14,"value":417},"Configuration that is not secret",{"type":9,"tag":10,"props":419,"children":420},{},[421,423,429],{"type":14,"value":422},"Not everything injected is a credential. Compose ",{"type":9,"tag":46,"props":424,"children":426},{"className":425},[],[427],{"type":14,"value":428},"configs",{"type":14,"value":430}," handle the rest — a settings file, an nginx conf, a seed script — without rebuilding the image:",{"type":9,"tag":39,"props":432,"children":435},{"className":433,"code":434,"language":371,"meta":48},[369],"services:\n  proxy:\n    image: nginx:alpine\n    configs:\n      - source: nginx_conf\n        target: /etc/nginx/conf.d/default.conf\n\nconfigs:\n  nginx_conf:\n    file: ./nginx.conf\n",[436],{"type":9,"tag":46,"props":437,"children":438},{"__ignoreMap":48},[439],{"type":14,"value":434},{"type":9,"tag":10,"props":441,"children":442},{},[443],{"type":14,"value":444},"Same mechanism as secrets, different intent, and the separation is worth keeping: it makes \"what in here is sensitive\" answerable by reading the file.",{"data":446,"body":447},{},{"type":6,"children":448},[449,604],{"type":9,"tag":450,"props":451,"children":453},"deep-dive",{"title":452},"Interpolation, `.env`, and the two files people confuse",[454,459,468,487,502,541,561,570,583],{"type":9,"tag":10,"props":455,"children":456},{},[457],{"type":14,"value":458},"Compose substitutes variables into the YAML before it does anything else:",{"type":9,"tag":39,"props":460,"children":463},{"className":461,"code":462,"language":371,"meta":48},[369],"services:\n  api:\n    image: myapp:${TAG:-latest}\n    ports:\n      - \"${PORT:?PORT must be set}\"\n",[464],{"type":9,"tag":46,"props":465,"children":466},{"__ignoreMap":48},[467],{"type":14,"value":462},{"type":9,"tag":10,"props":469,"children":470},{},[471,477,479,485],{"type":9,"tag":46,"props":472,"children":474},{"className":473},[],[475],{"type":14,"value":476},"${VAR:-default}",{"type":14,"value":478}," supplies a fallback; ",{"type":9,"tag":46,"props":480,"children":482},{"className":481},[],[483],{"type":14,"value":484},"${VAR:?message}",{"type":14,"value":486}," fails with your message if it is missing. Same syntax as the shell, deliberately.",{"type":9,"tag":10,"props":488,"children":489},{},[490],{"type":9,"tag":31,"props":491,"children":492},{},[493,495,500],{"type":14,"value":494},"Two different files, both called ",{"type":9,"tag":46,"props":496,"children":498},{"className":497},[],[499],{"type":14,"value":152},{"type":14,"value":501},", and mixing them up is a common half-hour.",{"type":9,"tag":10,"props":503,"children":504},{},[505,506,511,513,518,520,526,527,532,534,540],{"type":14,"value":381},{"type":9,"tag":46,"props":507,"children":509},{"className":508},[],[510],{"type":14,"value":152},{"type":14,"value":512}," file ",{"type":9,"tag":117,"props":514,"children":515},{},[516],{"type":14,"value":517},"next to your compose file",{"type":14,"value":519}," is read by Compose itself, to substitute ",{"type":9,"tag":46,"props":521,"children":523},{"className":522},[],[524],{"type":14,"value":525},"${…}",{"type":14,"value":172},{"type":9,"tag":31,"props":528,"children":529},{},[530],{"type":14,"value":531},"in the YAML",{"type":14,"value":533},". Those values are not passed to containers unless you also list them under ",{"type":9,"tag":46,"props":535,"children":537},{"className":536},[],[538],{"type":14,"value":539},"environment:",{"type":14,"value":79},{"type":9,"tag":10,"props":542,"children":543},{},[544,546,552,554,559],{"type":14,"value":545},"The file named by ",{"type":9,"tag":46,"props":547,"children":549},{"className":548},[],[550],{"type":14,"value":551},"env_file:",{"type":14,"value":553}," on a service is read at container start and becomes that container's ",{"type":9,"tag":31,"props":555,"children":556},{},[557],{"type":14,"value":558},"environment",{"type":14,"value":560},". Compose never interpolates with it.",{"type":9,"tag":39,"props":562,"children":565},{"className":563,"code":564,"language":371,"meta":48},[369],"services:\n  api:\n    image: myapp:${TAG:-latest}   # ← from ./.env\n    env_file:\n      - ./api.env                 # ← into the container's environment\n",[566],{"type":9,"tag":46,"props":567,"children":568},{"__ignoreMap":48},[569],{"type":14,"value":564},{"type":9,"tag":10,"props":571,"children":572},{},[573,575,581],{"type":14,"value":574},"So a variable in ",{"type":9,"tag":46,"props":576,"children":578},{"className":577},[],[579],{"type":14,"value":580},"./.env",{"type":14,"value":582}," that your app cannot see is usually this: it was interpolation-scoped, and nothing put it in the container.",{"type":9,"tag":10,"props":584,"children":585},{},[586,588,594,596,602],{"type":14,"value":587},"Finally, ",{"type":9,"tag":46,"props":589,"children":591},{"className":590},[],[592],{"type":14,"value":593},"docker compose config",{"type":14,"value":595}," renders the fully interpolated file with every override and ",{"type":9,"tag":46,"props":597,"children":599},{"className":598},[],[600],{"type":14,"value":601},"include",{"type":14,"value":603}," resolved. It is the fastest way to answer \"what is Compose actually going to run\" — and worth checking before you conclude a value is not being picked up.",{"type":9,"tag":10,"props":605,"children":606},{},[607],{"type":14,"value":608},"Next up: multi-platform images — one tag that runs on an ARM laptop and an x86 server.",1787908867683]