Dokploy (Docker)
The site is server-rendered with the Astro Node adapter and the EmDash CMS, so it
deploys as a long-running Node server rather than static files. The repo ships a
multi-stage Dockerfile that builds a standalone server (dist/server/entry.mjs).
Deploy on Dokploy
Section titled “Deploy on Dokploy”- Push the repo to GitHub.
- In Dokploy, create an Application from the repository using the Dockerfile build.
- Mount a persistent volume at
/app/data. This holds the SQLite database (emdash.db) and any local uploads, so content survives redeploys. - Set environment variables (see Environment Variables):
SITE_URL, theS3_*Cloudflare R2 credentials, and any optional analytics keys. - Expose port 4321 and point your domain at the service.
- Deploy. On a fresh volume, the entrypoint seeds content automatically (below).
Cloudflare R2 media
Section titled “Cloudflare R2 media”In production, media is stored in Cloudflare R2 through its S3-compatible API. Create
an R2 bucket and an S3 API token, then set S3_ENDPOINT, S3_BUCKET,
S3_ACCESS_KEY_ID, and S3_SECRET_ACCESS_KEY. If S3_BUCKET is unset, EmDash falls
back to local filesystem storage under ./data/uploads.
Uploads PUT directly to R2 from the browser via presigned URLs, so the bucket needs a
CORS policy allowing your site origin (GET, PUT, HEAD). The bucket can stay
private — EmDash serves media through its own proxy route
(/_emdash/api/media/file/<key>) that streams objects from R2, so URLs always look
same-origin and S3_PUBLIC_URL is not needed. See
Environment Variables → Media storage for the
exact CORS policy and details.
Enable compression (gzip / Brotli)
Section titled “Enable compression (gzip / Brotli)”The Astro Node adapter serves HTML, CSS, and JS uncompressed — it has no built-in compression. On its own that drags Lighthouse Performance down (a ~53 KB stylesheet ships in full, inflating FCP/LCP) even though the server is fast. Compress at the Traefik reverse proxy that Dokploy already runs.
In Dokploy: open the application → Advanced → Traefik and add a compress middleware,
then attach it to the app’s router. The dynamic config looks like:
http: middlewares: site-compress: compress: # Don't compress Server-Sent Events — EmDash's live preview uses them. excludedContentTypes: - text/event-stream routers: # Add the middleware to the existing router Dokploy generated for this app: <existing-router-name>: middlewares: - site-compressAfter saving, verify with curl -sI -H "Accept-Encoding: br,gzip" https://your-domain/
— the response should include content-encoding: br (or gzip). This typically takes
Lighthouse Performance from ~90 to ~100, since the render-blocking CSS and JS now transfer
at a fraction of their size.
The public marketing pages ship almost no JavaScript (the interactive bits are plain Astro + tiny inline scripts), so compression is the main remaining lever. The heavy React bundles you may see under
/_astrobelong to the EmDash admin and only load on/_emdashroutes.
Security headers (CSP / HSTS)
Section titled “Security headers (CSP / HSTS)”The app ships a middleware (src/middleware/security-headers.ts) that sends real
security headers on the public site: a strict Content-Security-Policy, plus
Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, Permissions-Policy, and Cross-Origin-Opener-Policy. The CSP is
nonce-free and 'unsafe-inline'-free for scripts — it buffers each HTML response and
computes a SHA-256 hash for every inline <script> actually emitted (the dark-mode
bootstrap, analytics snippets, …) and auto-allowlists external script origins, so it
stays strict without manual maintenance. The EmDash admin (/_emdash) is left to its own
CSP. Set DISABLE_SECURITY_HEADERS=1 to turn the middleware off (e.g. if your edge
already sets these).
Coverage note: Astro middleware runs for server-rendered routes only. The prerendered Starlight
/docspages, the404, and static assets under/_astroare served directly by the Node adapter and bypass the middleware. For blanket coverage (including those routes), also set headers at the Traefik layer — this is the recommended production setup:
http: middlewares: site-headers: headers: stsSeconds: 63072000 stsIncludeSubdomains: true stsPreload: true contentTypeNosniff: true referrerPolicy: strict-origin-when-cross-origin frameDeny: true permissionsPolicy: "camera=(), microphone=(), geolocation=(), browsing-topics=()" routers: <existing-router-name>: middlewares: - site-compress - site-headersLeave the per-app CSP to the middleware (it varies per page), or replicate a static CSP here if you prefer the proxy to own everything.
Database, migrations & seeding
Section titled “Database, migrations & seeding”The SQLite database is runtime state and is not committed to git - it lives on the
/app/data volume. On deploy:
- The server runs schema migrations automatically on the first request, creating the database and tables on the volume.
- The container
docker-entrypoint.shrunsemdash seedfromseed/seed.jsononly when no database exists yet (a fresh volume), so a first deploy comes up populated and redeploys never overwrite your data.
To seed or re-seed manually (e.g. outside Docker):
pnpm exec emdash seed --database data/emdash.db --uploads-dir data/uploadsAfterwards, content is edited through the admin panel at /_emdash/admin (create the
owner account on first visit) and requires no redeploy.
Local Docker test
Section titled “Local Docker test”docker build -t astro-emdash .docker run --rm -p 4321:4321 -v "$PWD/data:/app/data" astro-emdash