Chapter 12 of 13

Deploying to Coolify: A Docker Debugging Diary

I’m writing this chapter differently from the rest of the series. Instead of presenting a clean final Docker setup as if it arrived that way, I want to walk through the actual sequence of failures, in order, because that sequence is genuinely more useful than a polished writeup that skips straight to the working config. Every one of these happened on real deployment attempts to gatify.letsprogram.in, a Hostinger VPS running Coolify.

The shape of the deployment

One compose.production.yaml, four services: postgres, redis, api (NestJS), and web (Angular built to static files, served by Nginx). Only web is exposed publicly. Its Nginx config serves the Angular build and reverse-proxies /api/* and /v1/* to the private api service, so the frontend never needs a separate domain or any CORS configuration, everything is same-origin.

location /api/ {
    proxy_pass http://api:3700;
}
location /v1/ {
    proxy_pass http://api:3700;
    proxy_buffering off;
    proxy_read_timeout 10m;
}
location / {
    try_files $uri $uri/ /index.html;
}

That part I got right on the first attempt. Almost nothing else did.

Failure one: Nx modules missing entirely

The very first production build failed with NX Could not find Nx modules at "/app". Have you run npm/yarn install?, right in the middle of npx nx build api. Coolify’s build environment sets a production-flavored environment, and npm ci under those conditions skips devDependencies, which is exactly where Nx, the Angular CLI, and TypeScript itself live in this project’s package.json. The build image needs the whole dev toolchain to compile, even though the runtime image doesn’t. Fix:

RUN npm ci --include=dev --no-audit --no-fund
RUN npx --no-install nx build api --configuration=production

--include=dev forces devDependencies in regardless of the ambient NODE_ENV, and --no-install on npx makes it fail loudly if Nx somehow still isn’t there, instead of silently trying to download a throwaway copy, which is what was happening before and made the actual error harder to spot in the logs.

Failure two: apk on a Debian base image

Around the same time I was chasing an unrelated Docker layer failure, and swapped the API’s base image from node:24-alpine to plain node:24 to rule out an Alpine-specific issue. That fixed the thing I was chasing and broke something else instead: the Dockerfile still had RUN apk add --no-cache postgresql-client in the runtime stage, and apk simply doesn’t exist on Debian, which node:24 is built on. Exit code 127, command not found. The fix was to stop depending on pg_isready entirely and replace it with a tiny Node script for the readiness check in the entrypoint, which works identically regardless of which base image I’m on:

until node -e "const net = require('net'); const s = net.createConnection(5432, 'postgres'); s.on('connect', () => process.exit(0)); s.on('error', () => process.exit(1)); setTimeout(() => process.exit(1), 1000);"; do
  echo "Waiting for PostgreSQL..."
  sleep 2
done

No package manager dependency, no base-image coupling, just Node itself checking whether it can open a TCP connection.

Failure three: the web container ran fine and was still unreachable

The frontend built, the container started, Nginx logged a completely normal startup, start worker processes, no errors anywhere in its own logs, and the public domain still returned 503 no available server. This one took the longest to place correctly, because every log I looked at said everything was fine. The actual signal was in docker ps, not in any application log: the web container showed Up (unhealthy). Coolify’s proxy was correctly refusing to route traffic to a container it considered unhealthy, it just wasn’t obvious that “unhealthy” and “broken” aren’t the same thing.

The health check itself was the problem: wget --spider http://127.0.0.1/, which depends on Angular’s try_files SPA fallback actually resolving correctly, tangled up with whatever state the app’s static assets were in. I replaced it with a dedicated, dependency-free Nginx location that always returns 200 regardless of anything upstream:

location = /_health {
    access_log off;
    default_type text/plain;
    return 200 'ok\n';
}
healthcheck:
  test: ['CMD', 'curl', '--fail', '--silent', 'http://127.0.0.1/_health']

Once the health check couldn’t be dragged down by anything except Nginx itself being down, Coolify started routing to it correctly and the 503 went away.

Failure four: Tailwind imports shipped unprocessed

Last one, and probably the most subtle. The deployed site loaded, but the entire UI was unstyled, raw HTML with no Oat or Tailwind styling applied at all. Locally, everything looked fine. The difference was that the web Dockerfile’s build stage never copied postcss.config.json from the repo root into the build context:

{ "plugins": { "@tailwindcss/postcss": {} } }

Without that file present, @import 'tailwindcss/theme.css'; and @import 'tailwindcss/utilities.css'; in styles.css never got resolved into actual compiled utility classes at build time, they just shipped as literal, meaningless @import statements the browser couldn’t do anything with. One missing COPY line in the Dockerfile:

COPY postcss.config.json ./

And the production CSS output went from a few kilobytes of dead imports to the full compiled stylesheet, confirmed by actually grepping the built CSS file for a real Tailwind utility class before and after.

What I’d tell past me

Every one of these four failures produced a misleading first symptom: a generic Docker exit code, a container that looked healthy in its own logs while Coolify disagreed, a totally silent styling failure with no error anywhere. None of them were guessable from the error message alone. What actually worked, every single time, was reproducing the exact same build command locally with --no-cache and full plain-text --progress=plain output, so I could see precisely what the container saw, instead of trusting a truncated deployment log. I’d do that first next time, rather than iterating blind against a remote deployment platform’s log viewer.

Next, and last: an honest look back at the whole build, what I’d keep, and what I’d genuinely do differently if I started this over today.