2026-09-01

Coolify quietly bakes your build secrets into the image layers

Docker history on one of my images printed out a GitHub token, an OAuth token, and a handful of other secrets in plain text. Here is why, and the actual fix.

I was chasing a disk usage problem on my VPS on 10 August, poking around the Docker images sitting on the box. Out of habit I ran docker history on one of them, and the output stopped me. Right there in the layer history, in cleartext, was a GitHub personal access token, a Claude OAuth token, a Google Workspace client secret and refresh token, a dashboard JWT secret and password, and three Telegram bot tokens.

None of that was supposed to be in the image. These were build-time environment variables, the kind you set in Coolify and expect to disappear once the container is running. They do not disappear. They get burned into the image itself.

How I found it

This was not a targeted audit. I was on that box for an unrelated disk space issue and happened to run docker history against a built image to see what was taking up room. The command prints every layer's build instructions, and for this image that included every --build-arg Coolify had passed in, values included.

What's actually happening

Coolify deploys apps through an ApplicationDeploymentJob. For the docker-compose build pack, that job pushes every environment variable to the build as --build-arg <KEY>, unless a setting called use_build_secrets is turned on for that application. The guard in the code is a straight negation: it bakes the var in unless that flag is true. On every app I run, it was false.

Docker records build args in the image's layer metadata permanently. It means every image you have ever built for that app, sitting in your registry or on disk, is a working credential dump, even after you change the live secret. Rotating the value in Coolify does nothing to the old images.

It isn't the is_build_time flag

This is not controlled by the per-variable is_build_time setting, the one you tick when a var actually needs to be available during the build. On one app all 30 of its env vars already had is_build_time set to false, and every single one still got baked into the image. The only switch that matters is the application-level use_build_secrets setting. If that is off, is_build_time is irrelevant.

The fix

This does not require rotating anything to apply. You flip the setting on the affected application, then redeploy. From the Coolify host:

docker exec coolify php artisan tinker

$app = App\Models\Application::where('uuid','<uuid>')->first();
$app->settings->use_build_secrets = true;
$app->settings->save();

Then redeploy. Verify it worked by pulling docker history on the new image and comparing the ARG lines against the old one:

NEW:  ARG GITHUB_TOKEN
OLD:  ARG GITHUB_TOKEN=github_pat_<redacted, this was a real token>

The new image should declare the ARG with no value attached. The old image, and every image built before you flipped the setting, still has the value in it for as long as that image exists.

Audit by value, not by name

My first pass at figuring out how many apps were affected was to grep docker history output for ARG names I recognised, like GITHUB_TOKEN. That concluded only one app was affected. It was wrong. A second app, a portfolio site, had the exact same GitHub token sitting in its image, but it was embedded in a BuildKit RUN layer prefix rather than a plain ARG declaration. No ARG-name pattern was ever going to catch that, because the secret was not in an ARG line at all.

The fix is to grep by the shape of the value, not the name of the variable. Sweep docker history across every image for distinctive credential prefixes:

What to check on your own box

  1. Run docker history on a built image for each app you run through Coolify, and read the full output, not just a grep for names you expect.
  2. Check the use_build_secrets setting on every application, not just the ones you think handle sensitive vars.
  3. When you audit, grep by credential value prefix across the whole image history, not by env var name. A leaked secret does not have to arrive via a plain ARG line.
  4. Flip use_build_secrets to true and redeploy for anything currently false, then diff the old and new docker history output to confirm the ARG lines are now bare.
  5. Remember that old images already built keep the old secrets forever. Flipping the setting protects future builds, it does not retroactively clean anything already sitting in your registry.

Topics

Still stuck? Send it to Emsden Studio and get a straight answer on what it is and what fixing it costs.

More notes

All notes