Shipping a web app to Coolify
Read when: an app needs a Dockerfile, a first deploy to Coolify, a redeploy, or a broken deploy fixed. Owns: the runtime shape, the image, the web server config, the Coolify settings, the container checks and the deploy symptoms.
TL;DR
- Pick the runtime shape first: static (nginx serves built files) or server (Node runs the app). Everything else follows from the table below.
- Ship with a Dockerfile in the repo. Why: a local
docker build, CI and Coolify then run the same thing. - The build script runs the type check before the bundler. Why: a malformed file then fails the image build instead of shipping a broken page.
- Test the container, not
npm run preview: health route, a deep link, a retired-URL redirect, andCache-Controlonindex.html. Why: all four live in the server config, so only the container can break them.scripts/smoke.shchecks them. index.htmlis never cached; hashed assets are cached for a year. Why: a cachedindex.htmlpoints at asset names the new build deleted, and returning visitors get a blank page.- A history-mode SPA needs
try_files $uri $uri/ /index.html. Why: without it every deep link 404s while the home page works. - The health check runs inside the container, so the final image needs
curlorwget. - The app listens on
0.0.0.0, on the port set in Coolify. Why: Coolify's proxy can't reach127.0.0.1inside the container. - No secrets in the image:
.envstays out of the build context; secrets are runtime variables. - If Docker can't run where you are, say so. Never report a deploy as verified without the smoke test.
Pick the shape
| App | Build command | Output | Runtime | Port | Health route |
|---|---|---|---|---|---|
| Vite SPA (Vue, React) | npm run build |
dist/ |
nginx | 80 | nginx location = /healthz |
| Nuxt, prerendered | nuxt generate |
.output/public/ |
nginx | 80 | nginx location = /healthz |
| Nuxt or Nitro, server | nuxt build |
.output/ (self-contained) |
node .output/server/index.mjs |
3000 | server/routes/healthz.ts |
Pick static when every page can be built ahead of time and the app talks to APIs from the browser. Pick server when pages need per-request data on the server, secrets on the server, or server routes. A static app has no runtime variables and no secrets: everything is baked in at build time.
Adding a backend of its own (accounts, a database, an admin) is a different deploy: a second service, a database, secrets and migrations. Raise it as a decision before adding it.
Ship flow
-
Add the files for your shape from references/dockerfiles.md:
Dockerfile,nginx.confandsecurity-headers.conf(static only),.dockerignore,docker-compose.ymlfor local runs, and the health route. -
Make the build script type-check first, for example
"build": "vue-tsc --build && vite build"or"build": "nuxi typecheck && nuxt build". -
Build locally:
npm ci && npm run build. If the change is visual, open every changed page in a browser, light and dark, desktop and phone width. A green build says nothing about looks. -
Run and smoke-test the container:
docker compose up --build -d # maps 8080 on your machine to the container port WAIT=30 sh <skill>/scripts/smoke.sh http://localhost:8080 \ /healthz=200 \ /docs/getting-started=200 \ /old-pricing=301:/pricing \ 'header /index.html cache-control no-cache' \ 'header /docs/getting-started cache-control no-cache' \ 'header /assets/<a-hashed-file>.js cache-control immutable' docker compose downFor a Nuxt build, hashed files are under
/_nuxt/, not/assets/. For a server app, drop theindex.htmlheader checks unless the server sets them. -
Create the app in Coolify with the settings in references/coolify.md: Dockerfile build pack, port, variables, domain.
-
Push to the deployment branch. Coolify rebuilds and redeploys. Content that lives in the repo ships the same way: a content change is a redeploy.
-
Smoke-test the live URL with the same command and the real domain (no
WAIT). -
Add the CI job from references/debugging.md so a broken image fails the pull request instead of the deploy.
When Docker can't run here
A docker build that fails resolving the base image (blocked registry, a proxy, no daemon) is the
environment, not the Dockerfile. Say so, and check what you can. The step most likely to break the
image is a lockfile out of sync:
tmp=$(mktemp -d) && cp package.json package-lock.json "$tmp" && (cd "$tmp" && npm ci --ignore-scripts)
Where each topic lives
| Topic | File |
|---|---|
Dockerfile per shape, nginx.conf, security headers, .dockerignore, local compose, health route |
references/dockerfiles.md |
| Build packs, the settings for a new app, ports, build and runtime variables, health check behaviour, redeploys | references/coolify.md |
| Reading a broken deploy, the symptom → fix table, nginx pitfalls, the CI job | references/debugging.md |
| Container and live-URL checks | scripts/smoke.sh (usage in its header) |
Done when
- [ ] The image builds from a clean checkout with
docker build .. - [ ] The build script type-checks before it bundles.
- [ ]
.dockerignorekeepsnode_modules, build output and.env*out of the context. - [ ]
smoke.shpasses against the container: health, a deep link, each retired URL,no-cacheonindex.htmland on a deep link,immutableon a hashed file. - [ ] Coolify: Dockerfile build pack, the port matches the app, domain attached with TLS, secrets set as runtime-only variables.
- [ ] One health check is in charge (the image's
HEALTHCHECKor Coolify's), and it passes. - [ ]
smoke.shpasses against the live domain. - [ ] CI builds the image and runs
smoke.shon every pull request. - [ ] Retired URLs are redirected on the server and in the client router.