Deployed as a Docker image (SvelteKit adapter-node), run behind Coolify + Traefik as the reverse proxy.
Docker build (Dockerfile)
Multi-stage build:
-
buildstage —node:22-slim, installs pnpm via corepack, installs all deps (including devDependencies) with a frozen lockfile, thenpnpm run build. -
Dev-dependency pruning — rather than doing a fresh
pnpm install --prodin the runtime stage, the build stage runspnpm prune --prodagainst the already-resolvednode_modulestree, then explicitlyrm -rfs a specific list of dev-toolchain packages (typescript,vite,esbuild,rollup,lightningcss,@sveltejs/kit,@sveltejs/vite-plugin-svelte).This exists because a from-scratch
--prodinstall would re-resolve peer dependencies via pnpm'sautoInstallPeers— the icon packages (@lucide/svelte,@icons-pack/svelte-simple-icons) declaresvelteas a peerDependency, which transitively peer-requires@sveltejs/kit, which peer-requiresvite(pulling inesbuild/rollup/lightningcsstoo). pnpm treats peerDependencies as always-installed regardless of--prod, so a plain prod install would silently drag the entire dev toolchain back in. Pruning the existing tree avoids that re-resolution. This was verified safe by grepping the compiledbuild/serveroutput to confirm none of the pruned packages are everrequire()'d at runtime — adapter-node's build output is self-contained. -
runtimestage — freshnode:22-slim, copies over: the compiledbuild/output, the prunednode_modules,package.json,src/content(markdown, read from disk per request — see content-pipeline.md), andsrc/lib/server/fonts(OG image generation needs real font bytes, not system fonts).
node:22-slim has neither curl nor wget, so the Dockerfile's HEALTHCHECK shells out to Node's own http client to hit GET /healthz (+server.ts) instead.
ORIGIN must be set in production — adapter-node needs it to build absolute URLs and validate incoming request origins when running behind a reverse proxy (Traefik). Set it in Coolify's environment variables UI, never in a committed .env — this is deliberately excluded from version control. See the top-level README's env var table for everything else, all of which is optional.
BODY_SIZE_LIMIT matters if you plan to use /spotify-import for large exports (extended streaming history can be tens of MB across many files) — adapter-node's default (512kb) will reject them. Use a byte count with a K/M/G suffix, or the literal string Infinity — not 0, which SvelteKit treats as a 0-byte limit rather than "unlimited".
See the top-level README's Persistent data section for the SQLite files and upload directories under data/. The critical thing the Dockerfile's VOLUME ["/app/data"] line does not do: persist anything across a Coolify redeploy. Coolify replaces the container from the image on every deploy, so an anonymous Docker volume is discarded along with it.
Before the first real deploy, go to the app's Storages tab in Coolify and add a persistent volume mounted at /app/data (any volume name works). Skipping this step means spotify-history.db — irreplaceable, real user content — gets wiped on the very next redeploy.
A persistent volume only protects against redeploys, not against the server disappearing entirely — see backups.md for off-box backups.
razerghost.xyz is proxied through Cloudflare, which matters in two ways when you go to check whether a deploy landed.
It overrides the app's cache headers. og.png/+server.ts asks for Cache-Control: public, max-age=3600; the edge serves max-age=14400 (cf-cache-status: HIT, with an Age header counting up). That's Cloudflare's Browser Cache TTL setting winning over the origin's header, so a changed OG image can keep serving the old bytes for up to four hours after a successful deploy. If social previews need to update now rather than eventually, purge /devlog/*/og.png and /projects/*/og.png from the Cloudflare dashboard — and remember that some platforms cache the image on their own side once they have fetched it.
So verify a deploy with a cache-buster, never a plain GET. A plain curl of a cached asset can report "unchanged" indefinitely while the new build is live:
curl -s "https://razerghost.xyz/devlog/<slug>/og.png?cb=$(date +%s)" -o /dev/null -w '%{size_download}\n'The cheapest honest signal that a deploy landed is the footer, which prints the build timestamp (src/lib/deployed.ts):
curl -s "https://razerghost.xyz/?cb=$(date +%s)" | grep -o 'Last deployed [^<]*'