Dockerfiles and server config
Read when: adding a Dockerfile, nginx.conf, .dockerignore or a local compose file to an app.
Owns: the copy-ready files for each runtime shape, and the health route.
TL;DR
- Two stages: build with Node, then copy only the output into a small runtime image.
- Copy
package.jsonand the lockfile before the source, so thenpm cilayer is reused when only code changes. - No
# syntax=line. Why: it makes every build pull an extra frontend image from the registry for no gain with these files, and a blocked registry then fails the build. npm cineeds a committed lockfile in sync withpackage.json.- Every image ships a
HEALTHCHECKthat calls/healthzwithwget, which Alpine images carry through busybox. How Coolify treats it: coolify.md.
Base images (checked 2026-11-03)
| Image | Tag used here | Why |
|---|---|---|
| Node | node:26-alpine |
26 is the Active LTS (since 2026-10-28, end of life 2029-04-30). 24 is Maintenance LTS until 2028-04-30 if a dependency is not ready for 26. |
| nginx | nginx:1.30-alpine |
1.30 is the stable branch. 1.31 / mainline-alpine is mainline; stable is the one for production. |
Pin the minor line (1.30-alpine), not latest or alpine, so a rebuild can't jump a major
version. Move to the next LTS or stable line on purpose, with a rebuild and a smoke test.
Static SPA (Vite) served by nginx
Dockerfile
FROM node:26-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Values the bundle reads at build time (import.meta.env.VITE_*) must be declared here,
# or the Coolify variable never reaches the build. Remove if the app has none.
ARG VITE_API_URL
RUN npm run build
FROM nginx:1.30-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY security-headers.conf /etc/nginx/snippets/security-headers.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]
A VITE_* value is public: it ends up in the JavaScript every visitor downloads. Never put a secret
in one.
security-headers.conf
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
A separate file, included in server and again in every location that sets its own header. Why:
see add_header inheritance in debugging.md.
If the app loads fonts or scripts from another host and you add a Content-Security-Policy here,
list those hosts in it (for a hosted font service: the stylesheet host and the font-file host).
nginx.conf
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Relative Location headers: correct behind Coolify's proxy and any port mapping.
absolute_redirect off;
include /etc/nginx/snippets/security-headers.conf;
location = /healthz {
access_log off;
default_type text/plain;
return 200 'ok';
}
# Retired URLs people were given. Keep in step with the client router.
# Exact `=` matches and anchored regexes only; targets carry no #fragment.
location = /old-pricing {
return 301 /pricing;
}
location ~ "^/guide/([^/]+)/?quot; {
return 301 /docs/$1;
}
# Hashed build files never change.
location /assets/ {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Files in public/ that keep their name when replaced.
location /images/ {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, max-age=604800";
try_files $uri =404;
}
# The entry point is revalidated on every visit, or deploys don't reach returning visitors.
location = /index.html {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "no-cache, must-revalidate";
}
# History-mode routing: unknown paths fall back to the app.
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types text/plain text/css application/javascript application/json
image/svg+xml application/manifest+json;
}
Deep links reach /index.html through the last try_files argument, an internal redirect, so they
match location = /index.html and get no-cache too. The smoke test checks it on a deep link.
Nuxt, prerendered (nuxt generate) served by nginx
FROM node:26-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx nuxi generate
FROM nginx:1.30-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY security-headers.conf /etc/nginx/snippets/security-headers.conf
COPY --from=build /app/.output/public /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]
Use the SPA nginx.conf with three changes:
- Prerendered routes are files at
route/index.html, sotry_files $uri $uri/ …finds them. - Routes that were not prerendered fall back to the page Nuxt writes for that (
200.htmlor404.html, depending on the version). List.output/publicafter a build and point the lasttry_filesargument at the file that exists, for exampletry_files $uri $uri/ /200.html;, and give that file theno-cachelocation instead of/index.html. - Hashed files live under
/_nuxt/, not/assets/. Rename that location.
Values the pages read at build time (NUXT_PUBLIC_* used while prerendering) need an ARG line
before RUN npx nuxi generate, as in the SPA Dockerfile.
Nuxt or Nitro server (nuxt build) on Node
FROM node:26-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:26-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000
COPY --from=build /app/.output ./.output
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
CMD ["node", ".output/server/index.mjs"]
// server/routes/healthz.ts
export default defineEventHandler(() => 'ok');
.outputcarries its own traced dependencies, so the runtime stage needs nonpm ci.HOST=0.0.0.0is what makes the server reachable from Coolify's proxy.USER noderuns the server without root; the base image ships that user.- The longer start period covers server start-up before failures count.
.dockerignore
node_modules
dist
.output
.nuxt
.git
.github
.vscode
.idea
.DS_Store
*.md
.env
.env.*
npm-debug.log*
Dockerfile
.dockerignore
docker-compose.yml
Keep .env files out. Why: a file copied into a build layer stays in the image, even if a later
step deletes it. node_modules out keeps host binaries from replacing the ones npm ci installed.
docker-compose.yml for local runs
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- '8080:80' # local only; Coolify puts its own proxy in front
For a server app, map 8080:3000. The health check comes from the image, so it is not repeated
here. This file is for your machine; Coolify deploys the Dockerfile.