Umfangreiche Erweiterung der Skill-Bibliothek: Neue Skills für Humanisierung (Englisch/PT-BR), Design-Validierung, AI-SEO und Coolify-Deployment inkl. Regelwerke, Presets, Pattern-Referenzen, Testfälle und Automatisierungsskripte. Zusätzliche Skills für Revenue-Centric Design, Pier Cloud, OKF, Lebenslauf- und LinkedIn-Optimierung sowie zahlreiche Referenzdateien, Checklisten und YAML/JSON/Markdown-Templates. Einführung einer vollständigen OpenWiki-Dokumentation mit Architektur-, Domain- und Workflow-Beschreibungen, zentralem Index und automatisierten Updates. Modularer Aufbau, restriktive Lizenzen und umfassende Qualitäts- und Evaluationsmechanismen für alle neuen Inhalte.
14 KiB
Deploying Astro on Coolify
Production-tested patterns for deploying Astro sites on self-hosted Coolify (v4.x). Based on 17+ live deployments.
Build Pack Decision
| Scenario | build_pack | Notes |
|---|---|---|
| Astro v6+ (requires Node ≥22.12.0) | dockerfile |
Nixpacks can't pin minor version |
| Astro v5 or earlier | nixpacks |
NIXPACKS_NODE_VERSION=22 works |
Astro output: 'static' with package.json |
nixpacks |
start: npx serve dist -l 80 -s |
Astro output: 'static' (Dockerfile) |
dockerfile |
nginx serves directly |
| HTML/CSS only (no package.json) | static |
static_image: nginx:alpine |
Rule: For Astro v6+ and v7, always use dockerfile. Nixpacks resolves Node 22.11.0 from its internal nixpkgs archive, but Astro v6+ requires ≥22.12.0.
Astro v7 Docker base image rule: Use node:22-slim (Debian/glibc) for the build stage, NOT node:22-alpine. Sätteri's native binding only supports glibc. The runtime stage can still use Alpine/Caddy since it only serves files.
Dockerfile — Astro SSR (Node Adapter)
Requires
@astrojs/node@^11.0.0for Astro v7. The v10 adapter crashes at runtime withTypeError: app.getAdapterLogger is not a function.
# Use node:22-slim (NOT alpine) — Sätteri needs glibc for Astro v7
FROM node:22-slim AS build
WORKDIR /app
ENV NODE_OPTIONS="--max-old-space-size=512"
# Coolify injects env vars as ARG — must convert to ENV for npm run build
ARG MY_API_KEY
ARG PUBLIC_SITE_URL
ENV MY_API_KEY=$MY_API_KEY
ENV PUBLIC_SITE_URL=$PUBLIC_SITE_URL
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "dist/server/entry.mjs"]
Dockerfile — Astro Static (nginx)
# Use node:22-slim (NOT alpine) — Sätteri needs glibc
FROM node:22-slim AS build
WORKDIR /app
ENV NODE_OPTIONS="--max-old-space-size=512"
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
Critical Gotchas
ARG vs ENV — Build-time secrets
Coolify injects variables as Docker ARG. But ARG does NOT become an environment variable for child processes like npm run build. Astro/Vite resolves import.meta.env.VAR during build — if the variable doesn't exist in the process environment, it silently becomes undefined.
Fix: For every secret the build needs:
ARG RESEND_API_KEY
ENV RESEND_API_KEY=$RESEND_API_KEY
OOM on Resource-Limited Servers
The astro build process can die with exit code 255 and no clear error message on servers with limited RAM (~2GB).
Fix: Add to build stage:
ENV NODE_OPTIONS="--max-old-space-size=512"
Nixpacks Node Version
Nixpacks only accepts major version. NIXPACKS_NODE_VERSION=22 can resolve to 22.11.0, causing:
Node.js v22.11.0 is not supported by Astro!
Fix options:
- Use Dockerfile instead (recommended for Astro v6+)
- Set
NIXPACKS_NODE_VERSION=24(skips a major) - Pin nixpkgs archive via
nixpacks.toml:[phases.setup] nixpkgsArchive = "5ef6c8a1bf89a0bfe4e15e7baf5bab7feeff86a5"
Nixpacks Timeout
Nixpacks downloads ~600MB nixpkgs archive during build. On servers with limited bandwidth, build dies silently during unpacking step.
Fix: Switch to Dockerfile. node:22-alpine is ~50MB vs ~600MB.
Sätteri Native Binding on Alpine (Astro v7)
Astro v7 uses Sätteri (Rust-based Markdown) by default. Sätteri ships native bindings but only for glibc (@bruits/satteri-linux-x64-gnu). Alpine uses musl libc — no musl binding exists, and the WASM fallback has a cpu platform check that also fails.
Cannot find module '@bruits/satteri-linux-x64-musl'
Fix: Use node:22-slim (Debian/glibc) for the build stage. The runtime stage can still use Alpine since it only serves static files:
FROM node:22-slim AS build # glibc — satteri works
WORKDIR /app
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build
FROM caddy:2-alpine # runtime doesn't need Node
COPY --from=build /app/dist /srv
Affected projects: Any Astro v7 project using Sätteri (default) or Starlight 0.40+ on Alpine.
Not affected: Projects using unified() processor explicitly (they bypass Sätteri).
Sätteri Native Binding on ARM64 (Cross-Platform Lockfile)
When the dev machine is x86_64 but the Coolify build server is ARM64 (e.g., OCI Ampere), npm ci and even npm install --include=optional fail with:
Cannot find module '@bruits/satteri-linux-arm64-gnu'
Require stack:
- /app/node_modules/satteri/index.js
Root cause: The package-lock.json was generated on x86_64 and only includes @bruits/satteri-linux-x64-gnu in its optional dependency tree. npm respects the lockfile's platform resolution even on a different architecture — this is npm bug #4828.
What does NOT work:
.npmrcwithinclude=optional— npm still reads the lockfile's platform treenpm install --include=optionalin Dockerfile — lockfile still constrains resolution- Adding
@bruits/satteri-linux-arm64-gnutooptionalDependencies— npm may still skip it
Fix: Do NOT copy package-lock.json into the Docker build. Let npm resolve fresh on arm64:
FROM node:22-slim AS build
WORKDIR /app
ENV NODE_OPTIONS="--max-old-space-size=512"
COPY package.json .npmrc ./
# Deliberately omit package-lock.json — forces fresh resolution on arm64
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
Tradeoff: Build is slightly less deterministic (no lockfile pinning in Docker). For static sites this is acceptable. For SSR with strict reproducibility needs, generate the lockfile inside an arm64 container instead.
Affected: Any Astro v7 project building on ARM64 servers when lockfile was generated on x86_64. Confirmed working: valeria.med.br on OCI Ampere A1 via Coolify (2026-07-03).
legacy-peer-deps and npm ci in Docker
When using --legacy-peer-deps locally (required for Astro v7 due to transient peer dep conflicts in Starlight plugins), Docker's npm ci will fail unless the .npmrc is copied into the container.
Fix: Always copy .npmrc before npm ci:
COPY package*.json .npmrc ./
RUN npm ci
The .npmrc must contain:
legacy-peer-deps=true
@astrojs/node Must Be v11+ for Astro v7
Astro v7's runtime API changed — app.getAdapterLogger() was added and the standalone entry module depends on it. If @astrojs/node stays at v10, the container builds fine but crashes at startup:
TypeError: app.getAdapterLogger is not a function
at createAppHandler (dist/server/entry.mjs)
Coolify shows restarting:unknown or exited:unhealthy — the build log looks green, but the container crash-loops.
Fix: Always upgrade @astrojs/node to v11 together with Astro v7:
npm install astro@latest @astrojs/node@latest
Checklist for SSR v7 migration:
astro→^7.0.0@astrojs/node→^11.0.0@astrojs/mdx→^7.0.0(if used)
pnpm approve-builds in Docker (pnpm 11.9+)
pnpm 11.9+ blocks install scripts (postinstall, install) by default. Packages like esbuild and sharp need native binaries built after install. Without approval, pnpm install --frozen-lockfile fails:
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.28.1, sharp@0.34.5
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
Fix: Run pnpm approve-builds locally, which creates pnpm-workspace.yaml with:
allowBuilds:
esbuild: true
sharp: true
Then copy pnpm-workspace.yaml into the Docker container alongside the lockfile:
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
Missing this file = build fails in CI/Docker but works locally (because local node_modules already has the binaries).
package-lock.json Desync After Major Upgrade
After npm install --legacy-peer-deps for a major version upgrade, the lockfile may reference packages that npm ci (strict mode) cannot resolve. Symptoms: npm ci fails with "lock file's X does not satisfy Y".
Fix: Delete lockfile and regenerate:
rm package-lock.json node_modules -rf
npm install --legacy-peer-deps
# Then test: npm ci must pass
Integrations That Download ML Models (transformers.js, ONNX)
Integrations like @philnash/astro-related-content download ONNX models (~300MB) during astro build to generate embeddings. On Coolify servers with limited bandwidth/disk, this causes 20+ minute builds or disk exhaustion.
Pattern: Generate artifacts locally, commit them, skip the heavy integration in CI.
Fix: Dual-mode config with ENV CI=true in Dockerfile. See Related Content reference for complete implementation.
Key principle:
- Local: Integration runs fully (downloads model, generates embeddings)
- CI/Docker: Vite plugin serves pre-built
data.json(zero model download) - Cache commitado:
.astro-related-content/data.json+vectors.jsongo in git (~750KB for 32 posts)
This pattern applies to ANY integration that downloads large artifacts at build time.
Coolify API — Create App
COOLIFY_URL="https://cool.example.com/api/v1"
COOLIFY_KEY="your-token"
curl -sS -X POST "$COOLIFY_URL/applications/private-deploy-key" \
-H "Authorization: Bearer $COOLIFY_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_uuid": "PROJECT_UUID",
"environment_name": "production",
"server_uuid": "SERVER_UUID",
"private_key_uuid": "SSH_KEY_UUID",
"git_repository": "git@gitlab.com:user/project.git",
"git_branch": "main",
"build_pack": "dockerfile",
"dockerfile_location": "/Dockerfile",
"ports_exposes": "4321",
"name": "my-astro-site"
}'
Set Domain
# Use "domains", NOT "fqdn" — fqdn returns "field not allowed"
curl -sS -X PATCH "$COOLIFY_URL/applications/$APP_UUID" \
-H "Authorization: Bearer $COOLIFY_KEY" \
-H "Content-Type: application/json" \
-d '{"domains": "https://mysite.com"}'
Set Environment Variables
curl -sS -X POST "$COOLIFY_URL/applications/$APP_UUID/envs" \
-H "Authorization: Bearer $COOLIFY_KEY" \
-H "Content-Type: application/json" \
-d '{"key": "MY_VAR", "value": "secret-value", "is_preview": false}'
Do NOT send
is_build_time— API rejects it.
GitLab Webhook (auto-deploy on push)
curl -sS -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/hooks" \
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://cool.example.com/webhooks/source/gitlab/events/manual",
"token": "WEBHOOK_SECRET_FROM_APP",
"push_events": true,
"enable_ssl_verification": true
}'
Critical: The secret goes in the
"token"field (sent asX-Gitlab-Tokenheader), NEVER as?secret=query parameter in the URL. With secret in URL, Coolify returns 200 but does NOT trigger deploy.
Deploy and Validate
# Trigger deploy (Coolify 4.1+ — uses GET, NOT POST)
# The /deploy endpoint accepts uuid as query param, force=true rebuilds from scratch
curl -sS -X GET "$COOLIFY_URL/deploy?uuid=$APP_UUID&force=true" \
-H "Authorization: Bearer $COOLIFY_KEY"
# Returns: {"deployments":[{"message":"Application X deployment queued.","resource_uuid":"...","deployment_uuid":"..."}]}
# ⚠️ POST /applications/$UUID/deploy returns "Not found" in Coolify 4.1
# ⚠️ POST /applications/$UUID/restart only restarts existing container (no rebuild)
# Use restart when image is already built. Use /deploy?uuid=...&force=true for rebuild.
# Check status (~60s wait for build + container start)
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
"$COOLIFY_URL/applications/$APP_UUID" | python3 -c "
import sys,json; d=json.load(sys.stdin); print(d['status'])"
# Verify HTTP response
curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://mysite.com
Status interpretation:
| Status | Meaning |
|---|---|
running:healthy |
Container up and health check passing |
running:unknown |
Container up, no health check configured |
restarting:unknown |
Container crash-looping — check runtime logs |
exited:unhealthy |
Container stopped — likely build or startup failure |
Astro Config for Coolify SSR
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server', // or hybrid with per-page prerender
adapter: node({ mode: 'standalone' }),
server: { host: '0.0.0.0', port: 4321 },
});
For static output, no adapter needed — the Dockerfile handles nginx serving.
Port Configuration
| Output Mode | Port | CMD |
|---|---|---|
| SSR (Node adapter) | 4321 | node dist/server/entry.mjs |
| Static (nginx) | 80 | nginx default |
| Static (serve) | 80 | npx serve dist -l 80 -s |
Set ports_exposes in Coolify to match.
Recommended Stack (Homelab-Tested)
Based on 17 production Astro sites:
import seoGraph from '@jdevalk/astro-seo-graph/integration';
import agentmarkup from '@agentmarkup/astro';
import UnoCSS from '@unocss/astro';
import critters from 'astro-critters';
import compress from '@playform/compress';
// Key: compress() MUST be last integration
integrations: [mdx(), UnoCSS(), sitemap(), seoGraph(), agentmarkup(), critters(), compress()]
| Tool | Why |
|---|---|
| UnoCSS > Tailwind | 5x faster build, smaller bundle |
| @playform/compress > astro-compress | Better maintained |
| astro-critters | Critical CSS inlining |
| @jdevalk/astro-seo-graph | All-in-one SEO (replaces astro-seo + robots-txt + indexnow) |
| @agentmarkup/astro | LLM visibility (llms.txt, markdown mirrors) |
| Plausible > GA4 | 1kb script, no cookie banner, self-hosted |
| @philnash/astro-related-content | Semantic related posts via local embeddings (CI: use prebuilt data.json) |