Skip to content

Coolify in Practice: 10 Gotchas From Running Six Apps on One Server

Published on
Reading time
13 mins read

Last week I wrote about why and how I moved my side projects to Coolify. This is the follow-up: what bit me after the move.

The setup is one server running Coolify 4.3.x, with Traefik as the proxy. It hosts six apps: two Next.js apps (this site is one), a React app built with Vite, a Bun service, a FastAPI app and a Docker Compose app with Redis. There's also a shared PostgreSQL with a database per project, and drizzle-gateway as the database admin UI.

Few of these are bugs. Most are sensible defaults that still catch you out. They're roughly in order of how much they hurt.

The gotchas

1. A build-time NODE_ENV skips your devDependencies

Symptom. The first deploy of this very site failed. npm ci ran the prepare script, and husky install exited with code 127. Coolify even warns about it in the build log:

⚠️ Build-time environment variable warning: NODE_ENV=production
   Affects: Node.js/npm/yarn/bun/pnpm
   Issue: Skips devDependencies installation which are often required for building (webpack, typescript, etc.)
   Recommendation: Uncheck "Available at Buildtime" or use "development" during build

Why. New Coolify variables are available at build time and at runtime by default. The Dockerfile build pack injects build-time variables as ARG lines after every FROM. So npm ci sees NODE_ENV=production and skips dev dependencies, husky included. prepare then calls a binary that doesn't exist, and 127 is the shell's "command not found". An ENV NODE_ENV=production in a base stage that the install step inherits does the same, with or without Coolify. HUSKY=0 can't help: husky reads it itself, and husky was never installed.

Fix. Any one of these works:

  • Untick "Available at Buildtime" for NODE_ENV. The docs call it Build Variable.
  • Install with npm ci --include=dev in the build stage. --include beats the omit list, whatever NODE_ENV says.
  • Make prepare tolerate production installs. Husky v9's docs suggest husky || true, or a script that skips the install when NODE_ENV is production or CI is true. On v8, use husky install || true.

After a detour through Nixpacks (next gotcha), the site went back to its own multi-stage Dockerfile, now on Node 24. The build stage runs npm ci --include=dev with HUSKY=0, and NODE_ENV=production only applies at runtime. The general shape:

FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
ENV HUSKY=0
RUN npm ci --include=dev
COPY . .
RUN npm run build

FROM node:24-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
CMD ["node", "server.js"]

2. Nixpacks quietly picked Node 18

Symptom. While the Dockerfile build was broken, the site built with Nixpacks. That put it on Node.js 18, which reached end-of-life in April 2025. The deploy log only warned:

⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.
You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.

Nobody noticed for a week. The runtime logs had a second hint:

"next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.

Why. Nixpacks takes the Node version from NIXPACKS_NODE_VERSION, engines.node or .nvmrc, in that order. With none of them, it falls back to 18, and the repo had none. It also starts the app with the start script, here next start. But next.config.js had output: 'standalone', which expects node server.js. Both messages are only warnings, so the deploy succeeded.

Fix. Pin the version, either as a NIXPACKS_NODE_VERSION build variable (JSONShare does this) or in git with engines.node or .nvmrc. Then pick one way to start Next.js: standalone with your own Dockerfile, or next start without standalone. Or own the Dockerfile outright, as this site does now.

3. Health checks run curl or wget inside your container

Symptom. With the health check on and neither curl nor wget in the image, every deploy is marked unhealthy and rolled back:

WARNING: Dockerfile or Docker Image based deployment detected. The healthcheck needs a curl or wget command to check the health of the application. Please make sure that it is available in the image or turn off healthcheck on Coolify's UI.
New container is not healthy, rolling back to the old container.

Why. Coolify's HTTP check is a shell one-liner that runs inside the container:

curl -s -X 'GET' -f 'http://localhost:3000/' > /dev/null || wget -q -O- 'http://localhost:3000/' > /dev/null || exit 1

Slim images like node:24-slim (the base above) have neither tool. Coolify adds both to Nixpacks images, so this hits Dockerfile and Docker Image deployments.

Fix. Install curl in the final stage, or ship your own HEALTHCHECK. Coolify uses it instead of generating one:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
  CMD node -e "fetch('http://localhost:3000/').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

You can also turn the check off. The resource then shows running:unknown, since Docker has no health status to report.

I deliberately run some checks at long intervals, a few of them hourly. If you do, make the start period cover boot time. During a deploy, Coolify waits out the start period, then checks health once per interval. On Docker Engine 25+, Docker probes every 5 seconds during the start period, so the container can be healthy by the first check. If it isn't, the deploy waits a full interval.

4. Private repos need the GitHub App source

Symptom. A private repository added through the default "Public GitHub" source fails to clone: could not read Username for 'https://github.com'.

Why. The public source clones without credentials. A private repo needs them, and git can't ask.

Fix. Use a GitHub App source, or a deploy key if you can't install an app. In the API, that's POST /api/v1/applications/private-github-app with a github_app_uuid.

Get this right first. The API can't move an existing app to another source: PATCH rejects source_id. On the version I run, PATCH only takes fields from an allow-list, and none of the source fields are on it. I was working through the API, so I deleted the app and created it again. The UI is more forgiving: an app's Git source settings have a "Change Git source" action.

5. Restart doesn't pull a new image

Symptom. Updating drizzle-gateway taught me that a restart isn't an update. Coolify's Restart reuses the image already on the server, even when the tag is :latest.

Why. Restart recreates containers from local images. A tag resolves to an image when it's pulled and stays that way until the next pull.

Fix. For services, use Pull Latest Images & Restart in the UI, or pass latest=true to the API's restart endpoint:

curl -X POST -H "Authorization: Bearer $COOLIFY_TOKEN" \
  "https://coolify.example.com/api/v1/services/$SERVICE_UUID/restart?latest=true"

Per the docs, this pulls the tags already saved in the service. It won't bump a pinned version.

6. latest isn't always the latest

Symptom. When I checked drizzle-gateway's registry, latest pointed at 1.6.0, but 1.6.2 was already published. The image's own org.opencontainers.image.version label said 1.4.0-distroless. Three places, three versions.

Why. A tag is a pointer, and it only moves when the publisher moves it. The label is probably inherited. The same image's org.opencontainers.image.title is bun, so both labels look like leftovers from the Bun base image. latest has since caught up with 1.6.2, which proves the point: tags move, and nobody tells you.

Fix. Trust digests, not tags or labels:

# Same digest = same image
docker buildx imagetools inspect ghcr.io/drizzle-team/gateway:latest | grep '^Digest'
docker buildx imagetools inspect ghcr.io/drizzle-team/gateway:1.6.2 | grep '^Digest'

# The same with crane, plus the list of published tags
crane digest ghcr.io/drizzle-team/gateway:latest
crane ls ghcr.io/drizzle-team/gateway

# What the server actually has
docker image inspect --format '{{json .RepoDigests}}' ghcr.io/drizzle-team/gateway:latest

For anything you care about, pin an explicit version, so every update is one you chose.

7. No charts for Compose apps and services, but Sentinel has the data

Symptom. Coolify's docs say "Resource metrics are not available for Docker Compose applications or one-click service deployments." That's true in the UI: my Compose app and drizzle-gateway get no charts. But Sentinel, Coolify's metrics agent, still stores their CPU and memory samples in its SQLite database. The panel just doesn't draw them.

Why. Sentinel samples every container on the server. I run the defaults: a sample every 10 seconds, kept for 7 days. The UI only asks about application and standalone database containers. Sentinel's rows are also per container, not per Coolify resource. Each one is keyed by the coolify.name label, or by the container name if the label is missing.

Fix. I wrote a small read-only report. It sends a Python collector to the server over SSH. The collector copies Sentinel's SQLite file, WAL included, to a temp directory and queries the copy:

SELECT container_id,
       COUNT(*)                   AS samples,
       AVG(CAST(percent AS REAL)) AS cpu_avg,
       MAX(CAST(percent AS REAL)) AS cpu_max
FROM container_cpu_usage
WHERE CAST(time AS INTEGER) >= :since_ms  -- epoch ms, stored as text on my version
GROUP BY container_id;

The schema is internal, and newer Sentinel releases have already changed it, so treat this query as version-specific. The report then maps each key back to a resource and merges keys that land on the same one:

import re

UUID_RE = re.compile(r"[a-z0-9]{20,28}")  # the shape of Coolify resource UUIDs

def resource_for(key, by_name, by_uuid):
    if key in by_name:                  # a live container's name
        return by_name[key]
    for token in UUID_RE.findall(key):  # a resource UUID inside the key
        if token in by_uuid:
            return by_uuid[token]
    return None                         # deleted resource or old deploy

Averages are weighted by sample count, and the peak is the highest peak. The result is a 7-day CPU and RAM average and peak for every resource, Compose apps and services included. For longer history, a --snapshot mode appends the current readings to a local SQLite file. Run it on a schedule.

8. There's no disk usage view

Symptom. Coolify shows no disk usage: nothing per app, per volume or per project, and no chart. Per the docs, it does check the root filesystem against thresholds, both for a "Server Disk Usage" notification and for its Docker cleanup. That tells you the disk is filling up, not what's filling it.

Why. Coolify's metrics are CPU and memory. For disk it has a threshold, not a view.

Fix. The same report runs df, docker system df, and du on every Docker volume and every Coolify-managed directory. It matches each one to a resource by the container that mounts it, stopped containers included. Failing that, it looks for a resource UUID in the name. Whatever is left is orphaned: no container uses it and no resource claims it. The report lists orphans with their sizes and a reclaimable total. It never deletes anything.

That's by design. Coolify's cleanup can delete unused volumes, but that option is off by default, and the docs explain why: "Unused volumes can still contain important data." A volume left behind by a removed database container is a good example. Deleting stays a manual decision.

9. A project is not a network

Symptom. When I put drizzle-gateway in the same project as the shared Postgres, its connection to that database was already set up with the internal hostname. Don't read that as a networking rule.

Why. In the docs' words, a project "organizes resources; it is not a network boundary". Applications, standalone databases and the proxy share the coolify Docker network. Each service stack gets its own private network, and by default it can't reach anything on coolify.

Fix. Enable Connect To Predefined Network on the service (connect_to_docker_network in the API). drizzle-gateway is now on both networks and reaches Postgres by container name, like anything else on coolify:

postgres://APP_ROLE:APP_PASSWORD@postgres-internal:5432/APP_DB

postgres-internal stands in for the database container's name.

One more trap: the internal Postgres runs without SSL, since that traffic stays on the Docker network. A client that forces TLS hangs instead of failing fast. I wrote that one up separately.

10. The storage API says persistent, not volume

Symptom. Creating a volume through the API with "type": "volume" returned a 422. So did directory and bind.

Why. The storages endpoint accepts exactly two types. persistent is a named Docker volume and needs a name. file is a file mount. A directory mount is a file with is_directory: true and an fs_path.

Fix.

curl -X POST "https://coolify.example.com/api/v1/applications/$APP_UUID/storages" \
  -H "Authorization: Bearer $COOLIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type": "persistent", "name": "app-data", "mount_path": "/data"}'

Bonus: one thing that just worked

One of the apps is an internal dashboard with no login of its own. I turned on Coolify's HTTP basic auth for it (Configuration → General → HTTP Basic Authentication, or is_http_basic_auth_enabled in the API). Traefik now asks for credentials before anything reaches the app. WebSocket upgrades still work: I checked that the handshake returns 101 Switching Protocols behind the auth. To check yours:

curl -si --http1.1 --max-time 5 -u 'user:password' \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  https://internal.example.com/ws | head -n 1
# 101 Switching Protocols with credentials, 401 without

As the docs note, it's one shared credential, not user accounts. It keeps strangers out, but it can't tell users apart.

Summary

#GotchaWhat you seeFix
1Build-time NODE_ENVhusky install exits 127Runtime-only NODE_ENV; npm ci --include=dev
2Nixpacks' default NodeEOL Node 18, one warningPin the version, or own the Dockerfile
3Health check toolingEvery deploy rolled backAdd curl, ship a HEALTHCHECK, or skip the check
4Private repo, public sourcecould not read UsernameGitHub App source, chosen first
5Restart doesn't pullOld image keeps runningPull latest images, or latest=true
6Tags and labels driftWrong versionCompare digests, pin versions
7No Compose or service chartsNo metrics in the UIRead Sentinel's data per resource
8No disk viewUnknown disk usageOwn report; list orphans, don't delete
9Projects aren't networksNames don't resolve across stacksConnect To Predefined Network
10Storage API types422persistent or file

What I'd set up on day one

  • Keep NODE_ENV runtime-only, and build with npm ci --include=dev.
  • Pin the Node version in the repo, whatever builds the image.
  • Pick each app's health check up front: curl, your own HEALTHCHECK, or none. Use a start period that fits the interval.
  • Connect private repos through a GitHub App.
  • Pin image versions, update with a pull, and verify by digest.
  • Enable Sentinel metrics (Servers → your server → Configuration → Metrics) and read them per resource. Snapshot them if you need more than seven days.
  • Watch disk and orphaned volumes yourself, and never auto-delete volumes.
  • Give stacks that need the shared database Connect To Predefined Network, and check how they handle TLS.
  • Put basic auth in front of internal tools that have no login of their own.

Owning the platform means owning its defaults, too.