The Docker Concepts You Need Before Coolify Hides Them From You
Tutorial

The Docker Concepts You Need Before Coolify Hides Them From You

Coolify's whole pitch is that you shouldn't have to think about Docker, and most days that's true. The days it isn't are the days a container is 'running' but unhealthy, a redeploy quietly ate a volume, or an env var vanished — and the dashboard doesn't explain any of it.

Published August 31, 202612 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

What Docker concepts do you still need once Coolify is handling deploys for you?

Coolify automates the Dockerfile, the reverse proxy, and the TLS certificate, but it doesn't change what's underneath: images versus containers versus layers, why instruction order decides rebuild speed, which storage type survives a redeploy and which one silently doesn't, how containers find each other by name, why an env var can work locally and vanish in production, and what a container that's 'running' but unhealthy actually means. Coolify's dashboard surfaces a status badge and a deploy log; it doesn't replace docker logs, docker inspect, or reading an exit code, and the days something breaks are exactly the days you need those instead.

  • An image is a cached, layered template; a container is a running instance with its own writable layer — build failures and runtime failures are different problem classes
  • Docker builds instruction by instruction from the top; the first changed instruction invalidates every layer after it, which is why COPY order controls rebuild speed
  • An anonymous volume is the one that silently loses data on redeploy — named volumes and bind mounts both persist correctly, for different reasons
  • Containers only resolve each other by name on a user-defined network, not Docker's default bridge, which is why Coolify creates one per project
  • NEXT_PUBLIC_-prefixed env vars are inlined into the JS bundle at next build — changing them in Coolify's env panel after that point does nothing until the next rebuild
  • A HEALTHCHECK is opt-in — a container can be 'running' and completely unable to serve a request if nothing is checking

Evidence notes

Docker build cache

Docker's own docs describe cache invalidation top-down: the first instruction that changes invalidates its own layer and every layer built after it.

Volumes vs bind mounts

Docker's storage docs describe named volumes as Docker-managed and bind mounts as host-path-managed; anonymous volumes get a random name and are the default when a Dockerfile declares VOLUME without a compose override.

Container name resolution

Docker's networking docs confirm the default bridge network has no embedded DNS for container names — only user-defined networks get automatic name resolution via the embedded DNS server.

HEALTHCHECK semantics

Docker's Dockerfile reference documents the starting/healthy/unhealthy states and that failures during --start-period don't count against --retries.

NEXT_PUBLIC_ inlining

Next.js's own self-hosting guide states that NEXT_PUBLIC_-prefixed variables are inlined into the JavaScript bundle during next build, which is why a single Docker image can be promoted across environments only for server-side vars, not public ones.

Coolify's Railpack build pack

Coolify shipped Railpack as a beta build-pack option alongside its default Nixpacks in 2026, with build-time env var handling and config merging as part of the pitch.

Continue with purpose

Coolify's whole pitch is that you shouldn't have to think about Docker. Push to a branch, click deploy, and a reverse proxy, a TLS certificate, and a running container appear without you writing a Dockerfile by hand or memorizing `docker network create`. That's a genuinely good trade, most days — it's the reason a one-person team can run production infrastructure without a platform engineer on staff. The days it stops being a good trade are the days something breaks in a way the dashboard doesn't explain, and the abstraction Coolify built its whole product on top of is also the thing standing between you and the actual error.

What's actually different between an image, a container, and a layer?

An image is a read-only template, built from a stack of filesystem diffs called layers, where each Dockerfile instruction typically produces one more layer on top of the last. Those layers are content-addressed, which is the whole reason Docker can share a layer between two unrelated images without storing it twice — if two Dockerfiles both run the same `apt-get install` against the same base, that layer exists once on disk, not once per image. A container is a running (or stopped) instance created from an image, with one additional writable layer stacked on top for anything the process changes while it's alive. Delete the container and that writable layer goes with it; the image underneath is untouched. If the hard part of build cache for you is organisational rather than technical, XenGrowth is worth reading.

This distinction is exactly what decides where you should be looking when Coolify shows you a failure. A build problem means the image itself never finished — nothing is running yet, and the fix lives in the Dockerfile or the build context. A runtime problem means the image built fine and a container was created from it successfully, and something is wrong with that specific running instance right now — a crash, a bad connection, a health check failing. Coolify's deploy log blends both into one scrolling stream, so the first useful thing to establish, before reading anything else, is which of the two you're actually looking at.

Why does one rebuild take four seconds and the next take four minutes?

Docker builds an image instruction by instruction, from the top of the Dockerfile down, and it caches the result of each one. On a rebuild, it checks each instruction in order: if that instruction and everything before it are unchanged, it reuses the cached layer instead of running it again. The moment one instruction changes, Docker invalidates that layer and every layer after it, no matter how unrelated they look. That's the entire rule, and it explains almost every confusing rebuild-time complaint people bring to a Docker forum.

In practice this means instruction order is a performance decision, not just a style preference. Copy your dependency manifest and install dependencies before you copy your application source, and a code-only change invalidates only the layers after that split — dependency installation stays cached. Get the order backwards, copying everything at once, and editing a single line of application code invalidates the dependency install too, turning a four-second rebuild into a four-minute one for no reason related to what you actually changed. Coolify's 2026 Railpack build pack, offered in beta alongside its default Nixpacks, handles a lot of this layer ordering for you automatically for common frameworks — but it's still building a Dockerfile under the hood, and a custom Dockerfile you wrote yourself gets none of that help unless you order it correctly. If the operations side of this is the part you are stuck on, The XenGrowth resource library is the better reference.

Which storage type is the one that silently loses your data?

This is the one that actually bites people, usually the first time they redeploy a database. Docker gives you three ways to persist data outside a container's own writable layer, and they behave differently in exactly the moment you need them not to.

Dimension

Named volume

Bind mount

Anonymous volume

Where the data lives

A Docker-managed area under /var/lib/docker/volumes, invisible to normal host tools

Wherever you point it — an explicit host path you chose

The same Docker-managed area as a named volume, but with a random, unlabeled name

Persists across `docker rm`?

Yes — has to be deleted explicitly with `docker volume rm`

Yes — it's just a host directory Docker never owned to begin with

Yes, unless the container is removed with `-v` or `--rm` and nothing else references it

What happens on a Coolify redeploy

Fine — declared by name, reattaches to the new container automatically

Fine — same host path, the new container mounts it the same way

This is where data disappears — a fresh container gets a fresh anonymous volume unless the compose file names it explicitly

When to actually use it

Data you want Docker to manage and don't need to browse directly — a Postgres data directory, uploaded files

Config files or anything you need to edit or inspect from the host filesystem directly

Almost never on purpose — it's what a Dockerfile's `VOLUME` instruction gives you if your compose file doesn't override it with a named one

The failure mode in that table is specific: a base image's Dockerfile declares `VOLUME /var/lib/postgresql/data` so the container isn't writing state into its own removable layer, which is the right instinct. But if your own compose file doesn't also name a volume at that same path, Docker quietly gives you an anonymous one instead. It works fine right up until the container is recreated — which a redeploy does by design — and the new container gets a brand-new anonymous volume with nothing in it. Docker's own storage docs are worth reading end to end once, specifically for this, because the fix is one line in a compose file and the failure looks identical to actual data corruption until you know to check.

How do containers find each other by name?

Docker's default bridge network — the one you get if you don't create anything explicitly — has no name resolution between containers at all. Two containers on it can only reach each other by IP address, and that IP changes every time either container restarts. A user-defined bridge network is different: Docker runs an embedded DNS server inside it, and any container on that network can resolve any other container's name directly, no configuration required beyond both containers being attached to it.

Coolify creates a user-defined network per project specifically so this works by default, which is exactly why `postgres:5432` as a hostname just works from your app container without you ever running `docker network create` yourself. When it doesn't work — "connection refused" to a database that's demonstrably running — the actual cause is almost always that a container isn't attached to the network you assumed it was on, not that the database process itself has failed. `docker inspect <container> --format '{{json .NetworkSettings.Networks}}'` answers that in one line, and it's worth checking before you go looking for a database problem that isn't there. For the AI agents and marketing automation angle, see XenGrowth on governed AI marketing workflows.

Why does an env var work locally and vanish in production, or the other way around?

This one is specific to Next.js, and it's not a Coolify quirk — it's the framework's own build model. Any environment variable prefixed `NEXT_PUBLIC_` gets inlined directly into the JavaScript bundle at `next build` time. It isn't read from the environment when the container starts; by the time the container starts, that value is already baked into static files that shipped inside the image. Changing it in Coolify's environment-variables panel after the image already built does nothing at all until you trigger an actual rebuild — a redeploy or a restart of the existing image changes nothing, because the bytes it would need to change are already compiled.

Server-only environment variables behave the opposite way, which is exactly what makes the two easy to confuse. Next.js 16 supports reading them at genuine request time, inside dynamic rendering, by calling `connection()` first — so the same Docker image can be promoted through staging and production with different server-side values, and a plain restart with a new env var actually takes effect. The rule that resolves the confusion in one sentence: if it's prefixed `NEXT_PUBLIC_`, it's a build-time decision baked into the image; if it isn't, and your code reads it correctly, it's a runtime decision the container picks up fresh. Coolify's build-arg and runtime-env sections in its UI map onto exactly that split — they're not the same setting with two names, they run at genuinely different points in the pipeline.

What does 'running' but unhealthy actually mean?

Docker's own process supervision only promises one thing: the container's main process hasn't exited. It never promises the process is doing its job. A Next.js server can be alive, holding its port open, accepting TCP connections, and still be completely unable to render a page — deadlocked on a database connection, stuck behind a broken dependency, wedged in a way that never triggers an exit. Left alone, that container sits there marked 'running' indefinitely, because nothing exited and nothing is checking. If AI search, GEO and discovery is the part you are stuck on, XenGrowth on building one SEO and GEO content system is the better reference.

A HEALTHCHECK instruction is what closes that gap, and it's opt-in — Docker doesn't add one for you. It runs a command inside the container on an interval, and after enough consecutive failures the container's status flips to unhealthy without killing or restarting anything on its own; that decision is left to whatever's watching, which for a Coolify deployment is Coolify itself. The `--start-period` setting matters more than it looks: failures during that window don't count against the retry limit, which is the difference between a slow-booting app getting a fair chance to come up and getting marked unhealthy before it's even finished starting.

Symptom

Likely cause

Where to check

Container restarts in a loop

Crash on startup — missing env var, a failed migration, a port already bound inside the container

`docker logs <container>` for the actual stack trace, not Coolify's truncated deploy log

'Running,' but requests time out

A healthcheck that's passing (or absent) while the app itself is wedged on something like a stuck database connection

`docker inspect --format '{{json .State.Health}}'` for the healthcheck's own recent-attempt history

Exit code 0

A clean, intentional exit — often a misconfigured entrypoint for something meant to run forever

The command actually being executed: `docker inspect --format '{{.Config.Cmd}}'`

Exit code 1

The application itself threw an unhandled error and exited on its own

Application logs — Docker's part of the story is already over by this point

Exit code 137

SIGKILL — check whether it was actually memory before assuming it

`docker inspect --format '{{.State.OOMKilled}}'`, then dmesg or journalctl -k if that's true

Build succeeds, deploy still fails

The image is fine; the failure is in that container's own runtime environment — a missing mount, the wrong network

Coolify's runtime logs tab specifically, not its build log tab — they're not the same failure

A container Coolify shows as green and 'running' can still be completely unable to serve a single request. Docker's own supervision only ever promised the process didn't exit — it never promised the process is doing its job. That gap is precisely why HEALTHCHECK exists, and it's opt-in, not automatic.

What's the actual order of operations when Coolify's dashboard doesn't explain a failure?

The dashboard is genuinely useful for the 95% of deploys that just work, and genuinely unhelpful for the remaining ones, because its status badge is a summary, not a diagnosis. When it doesn't explain what's wrong, this is the order that actually resolves it fastest, working from the outside in rather than guessing at layers you haven't confirmed yet.

  1. `docker ps -a` on the box itself, to see whether a container exists at all, and whether it's actually running, restarting, or exited — before trusting anything the dashboard summarized.

  2. `docker logs <container>` for the real application output, since Coolify's own deploy log is about the build, not what the running process is doing right now.

  3. `docker inspect --format '{{json .State.Health}}'` if a HEALTHCHECK exists, to see the actual failing command's output rather than just the pass/fail badge.

  4. `docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}'` to rule memory in or out before chasing an application bug that isn't there.

  5. `docker inspect --format '{{json .NetworkSettings.Networks}}'` if the symptom looks like a connectivity problem between two containers on the same project.

None of this is an argument against Coolify — I run it, and the whole reason it's worth running is that this checklist is rarely the one you need. It's an argument for knowing the five commands above before the day you need them, because that's precisely the day a dashboard summary stops being enough and the actual answer is sitting one layer down, in the Docker layer Coolify built itself on top of. Compare that against how much of this same layer Dokploy abstracts differently, and the honest takeaway holds either way: the platform changes how often you need this list, not whether you need it at all.

Further reading from XenGrowth

Where this work meets go-to-market

If build cache is part of a growth programme rather than a standalone build, XenGrowth's marketing operations practice is the companion reading.

Do the abstractions still hold up?

Five questions on the Docker behaviour Coolify hides until something breaks. Answers and reasoning at the end.

1 / 5
You redeploy an app whose uploads live in a directory that was never declared as a volume. What happens to the uploads?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

DockerCoolifyvolumeshealthchecksbuild cacheself-hostingenvironment variablestutorials

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

How to Self-Host Next.js With Coolify: A Complete 2026 Walkthrough

Every Coolify tutorial stops at "push to deploy." This one covers the parts that actually break a real app: standalone output, a build that OOMs on a small box, env vars baked in at the wrong time, and a health check that would have caught all of it.

Navigate

How I Restore an Entire Server From Backup (And How Long It Takes)

The order matters more than people expect, and the answer to "how long will this take" isn't a number I can hand you — it's dominated by your database size, your download bandwidth, and one DNS setting most people only think to change after they needed to.

Navigate

How I Back Up PostgreSQL to S3-Compatible Storage on a Schedule

pg_dump, pg_basebackup and WAL archiving aren't three ways to do the same thing — they answer three different questions about how much data you're willing to lose. Here's which one to run, from a container that has no business having shell access to your host, pushed to storage that makes restoring free instead of expensive.

Navigate

Docker Compose Is More Powerful Than You Think

Most people learn Compose from a five-line docker-compose.yml in a README and stop there. Profiles, real healthchecks, resource limits, and layered override files cover most of what people reach for Kubernetes to get — you're probably one flag away from using the tool you already have.

Navigate

Wildcard Domains With Coolify and Cloudflare

A wildcard certificate can't be proven the way a normal one is — there's no single page to fetch for a domain that doesn't exist yet. That's why it needs a DNS record instead of an HTTP request, and why Cloudflare's API has to be involved at all.

Navigate

How I Get Alerted the Moment a Self-Hosted App Goes Down

A monitor that checks the wrong thing, sends to a channel you don't watch, and pages you for every 30-second blip is worse than no monitor — it trains you to ignore it. Here's how to build alerting that actually works, for one person who eventually has to sleep.

Navigate

Setting Up a Firewall for Self-Hosted Apps (and Docker's Nasty UFW Surprise)

ufw status can say everything's locked down while a container you published with -p 5432:5432 sits wide open to the internet. This isn't a misconfiguration — it's Docker rewriting your firewall's decisions before ufw ever gets a vote, and the only way to know for sure is to check from a machine that isn't the one you're worried about.

Navigate

How to Back Up and Restore a Coolify Server Properly

Coolify's built-in backup covers less than most people assume, and a filesystem copy of a live Postgres data directory is not a valid database backup no matter how confident it looks in a file listing. Here's what actually has to be backed up, and a restore you've actually tried before you need it.

Navigate

Where console.log Actually Goes When You Self-Host Next.js

On Vercel, a console.log just shows up in a dashboard somewhere. Self-hosted, it goes through a chain most people never trace end to end — and the two most common questions I get are why a log doesn't show up at all, and why the disk filled with logs nobody remembers writing.

Navigate

My Free Monitoring Stack for Self-Hosted Apps

Vercel gives you monitoring whether you ask for it or not. A VPS gives you a blank terminal and the assumption you'll figure it out. Here's what to actually watch on a self-hosted box, with tools that cost nothing, and why watching from the box itself is the one setup that will lie to you.

Navigate