Coolify Deployment
Deploy LinkDen on Coolify, an open-source self-hosted PaaS alternative to Heroku and Vercel.
Coolify Deployment
Coolify is an open-source, self-hosted Platform as a Service (PaaS). It provides a Heroku/Vercel-like experience on your own infrastructure -- automatic deployments, SSL certificates, and a web dashboard for managing services, all running on a server you control.
This guide covers deploying both the LinkDen API server and web app on Coolify using Docker.
Why Coolify?
- Self-hosted: Runs entirely on your own server with no vendor lock-in.
- Free and open-source: No per-app fees or usage-based billing.
- Automatic SSL: Let's Encrypt certificates are provisioned and renewed automatically.
- Git-based deployments: Push to a branch and Coolify rebuilds automatically.
- Docker-native: Full control over your build and runtime environment.
- Built-in database management: SQLite volumes, PostgreSQL, MySQL, and more.
Prerequisites
-
A Coolify instance running on a server. If you do not have one:
- Minimum server: 2 CPU cores, 2 GB RAM, 30 GB disk (a $12/month VPS from Hetzner, DigitalOcean, or similar).
- Install Coolify with the one-line installer:
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash- Access the Coolify dashboard at
http://your-server-ip:8000.
-
A domain name (optional but recommended for SSL).
-
A Clerk application set up at clerk.com with your API keys ready.
-
The LinkDen repository accessible from your Coolify instance (public GitHub repo or a connected private repo).
Architecture on Coolify
Since Coolify does not have Cloudflare D1, LinkDen uses a SQLite file instead of D1 for the database. The architecture looks like this:
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Coolify Service: │────▶│ Coolify Service: │────▶│ SQLite File │
│ Web App (Next.js │ │ API Server (Hono │ │ (persistent │
│ static via Caddy) │ │ on Node.js) │ │ volume) │
└──────────────────────┘ └──────────────────────┘ └──────────────┘Both services run as Docker containers managed by Coolify, with SQLite stored on a persistent Docker volume.
Step 1: Prepare the Dockerfiles
Coolify deploys using Docker, so you need Dockerfiles for both services. Create them in the project root.
API Server Dockerfile
Create Dockerfile.api in the repository root:
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@10.29.3 --activate
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/server/package.json apps/server/
COPY packages/db/package.json packages/db/
COPY packages/email/package.json packages/email/
COPY packages/validators/package.json packages/validators/
RUN pnpm install --frozen-lockfile
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/server/node_modules ./apps/server/node_modules
COPY --from=deps /app/packages/ ./packages/
COPY . .
RUN pnpm --filter @linkden/server build 2>/dev/null || true
# Runtime
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache sqlite
COPY --from=builder /app/ ./
# Create data directory for SQLite
RUN mkdir -p /data
ENV NODE_ENV=production
ENV DATABASE_URL=file:/data/linkden.db
EXPOSE 8787
CMD ["node", "apps/server/dist/index.js"]Note: Since the Hono server is designed for Cloudflare Workers, you may need to adapt the entry point to run on Node.js. A practical approach is to use @hono/node-server:
# Alternative CMD using tsx for direct TypeScript execution
CMD ["npx", "tsx", "apps/server/src/node-entry.ts"]Create apps/server/src/node-entry.ts for Node.js compatibility:
import { serve } from "@hono/node-server";
import app from "./index";
const port = Number(process.env.PORT) || 8787;
console.log(`Starting LinkDen API server on port ${port}`);
serve({
fetch: app.fetch,
port,
});You will also need to add @hono/node-server and a SQLite driver to the server dependencies:
cd apps/server
pnpm add @hono/node-server
cd ../..
cd packages/db
pnpm add better-sqlite3 @libsql/client
pnpm add -D @types/better-sqlite3Web App Dockerfile
Create Dockerfile.web in the repository root:
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@10.29.3 --activate
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY packages/ui/package.json packages/ui/
COPY packages/validators/package.json packages/validators/
RUN pnpm install --frozen-lockfile
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages/ ./packages/
COPY . .
# Build-time environment variables (NEXT_PUBLIC_* must be set at build time)
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_SITE_NAME=LinkDen
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ARG NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
ARG NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
ARG CLERK_SECRET_KEY
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_NAME=$NEXT_PUBLIC_SITE_NAME
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_CLERK_SIGN_IN_URL=$NEXT_PUBLIC_CLERK_SIGN_IN_URL
ENV NEXT_PUBLIC_CLERK_SIGN_UP_URL=$NEXT_PUBLIC_CLERK_SIGN_UP_URL
ENV CLERK_SECRET_KEY=$CLERK_SECRET_KEY
RUN pnpm --filter @linkden/web build
# Serve with Caddy
FROM caddy:2-alpine AS runner
COPY --from=builder /app/apps/web/out /srv
COPY <<'EOF' /etc/caddy/Caddyfile
:3001 {
root * /srv
file_server
try_files {path} {path}.html /index.html
header {
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
EOF
EXPOSE 3001Step 2: Create a Docker Compose File (Optional)
For local testing before deploying to Coolify, create docker-compose.yml:
services:
api:
build:
context: .
dockerfile: Dockerfile.api
ports:
- "8787:8787"
environment:
- CLERK_SECRET_KEY=${CLERK_SECRET_KEY}
- CLERK_PUBLISHABLE_KEY=${CLERK_PUBLISHABLE_KEY}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3001}
- APP_URL=${APP_URL:-http://localhost:3001}
- DATABASE_URL=file:/data/linkden.db
- RESEND_API_KEY=${RESEND_API_KEY:-}
- RESEND_FROM_EMAIL=${RESEND_FROM_EMAIL:-}
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
volumes:
- linkden-data:/data
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8787/"]
interval: 30s
timeout: 10s
retries: 3
web:
build:
context: .
dockerfile: Dockerfile.web
args:
- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8787}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:3001}
- NEXT_PUBLIC_SITE_NAME=${NEXT_PUBLIC_SITE_NAME:-LinkDen}
- NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}
- CLERK_SECRET_KEY=${CLERK_SECRET_KEY}
ports:
- "3001:3001"
depends_on:
api:
condition: service_healthy
volumes:
linkden-data:Step 3: Set Up the Project in Coolify
- Open your Coolify dashboard.
- Navigate to Projects and click New Project.
- Name it
LinkDenand click Create. - Click into the project and create a new Environment (e.g.,
production).
Step 4: Deploy the API Service
- In your project environment, click New Resource.
- Select Docker (or Docker Compose if you want to deploy both services together).
- Choose your source:
- GitHub: Connect your GitHub account and select the LinkDen repository.
- Public repository: Enter the repository URL.
- Configure the service:
- Name:
linkden-api - Dockerfile path:
Dockerfile.api - Build context:
.(repository root) - Port:
8787
- Name:
- Click Deploy.
Using Docker Compose in Coolify
Alternatively, deploy both services at once with Docker Compose:
- Click New Resource > Docker Compose.
- Point to the
docker-compose.ymlin the repository. - Coolify detects both services and creates them together.
Step 5: Deploy the Web App Service
If deploying services separately (not via Docker Compose):
- Click New Resource > Docker.
- Configure:
- Name:
linkden-web - Dockerfile path:
Dockerfile.web - Build context:
.(repository root) - Port:
3001
- Name:
- Add the build arguments (see Step 6).
- Click Deploy.
Step 6: Configure Environment Variables
In each service's settings in the Coolify dashboard, add the required environment variables.
API Service Variables
| Variable | Value | Notes |
|---|---|---|
CLERK_SECRET_KEY | sk_live_... | Mark as secret |
CLERK_PUBLISHABLE_KEY | pk_live_... | |
CORS_ORIGIN | https://yourdomain.com | Must match web app URL |
APP_URL | https://yourdomain.com | |
DATABASE_URL | file:/data/linkden.db | SQLite file path |
RESEND_API_KEY | re_... | Optional, mark as secret |
RESEND_FROM_EMAIL | contact@yourdomain.com | Optional |
TURNSTILE_SECRET_KEY | 0x... | Optional, mark as secret |
Web App Build Arguments
Since NEXT_PUBLIC_* variables must be available at build time, add them as Build Arguments in Coolify:
| Argument | Value |
|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | pk_live_... |
NEXT_PUBLIC_API_URL | https://api.yourdomain.com |
NEXT_PUBLIC_SITE_URL | https://yourdomain.com |
NEXT_PUBLIC_SITE_NAME | LinkDen |
NEXT_PUBLIC_TURNSTILE_SITE_KEY | 0x... (optional) |
CLERK_SECRET_KEY | sk_live_... |
Step 7: Configure Clerk
Update your Clerk application to allow the Coolify deployment URLs:
- Go to the Clerk dashboard.
- Open your application.
- Under Domains, add your production domain (e.g.,
yourdomain.com). - Under Paths:
- Sign-in URL:
/sign-in - Sign-up URL:
/sign-up - After sign-in redirect:
/admin
- Sign-in URL:
Step 8: Set Up Custom Domains
In the Coolify dashboard for each service:
- Click on the service.
- Go to Settings (or the Domain section).
- Add your custom domain:
- Web app:
yourdomain.comorlinks.yourdomain.com - API:
api.yourdomain.com
- Web app:
At your DNS provider, add A records pointing to your Coolify server's IP address:
| Type | Name | Target | TTL |
|---|---|---|---|
| A | @ or links | your-server-ip | 300 |
| A | api | your-server-ip | 300 |
If your server has an IPv6 address, add AAAA records as well.
Step 9: SSL with Let's Encrypt
Coolify automatically provisions Let's Encrypt SSL certificates for your custom domains. No manual configuration is required.
After adding a custom domain in Step 8:
- Coolify detects the domain and requests a certificate from Let's Encrypt.
- The certificate is provisioned within a few minutes (DNS must be propagated first).
- Coolify automatically renews certificates before they expire.
- HTTP traffic is automatically redirected to HTTPS.
To verify SSL is working, visit your domain with https:// and check for the lock icon in the browser address bar.
If SSL provisioning fails:
- Verify your DNS records are correct and propagated (use
dig yourdomain.com A). - Ensure ports 80 and 443 are open on your server's firewall.
- Check the Coolify logs for Let's Encrypt errors.
Step 10: Persistent Storage for SQLite
The SQLite database must be stored on a persistent volume so data survives container restarts and redeployments.
If Using Docker Compose
The docker-compose.yml already defines a linkden-data volume mounted at /data. Coolify manages this volume automatically.
If Using Separate Services
- In the Coolify dashboard, click on the API service.
- Go to Storages (or Volumes).
- Add a new persistent volume:
- Mount path:
/data - Name:
linkden-data
- Mount path:
- Redeploy the service.
Verify the database persists across restarts:
# SSH into your server
docker exec -it <api-container-id> ls -la /data/
# You should see linkden.dbBackup Strategy
Back up the SQLite database regularly:
# From the host server
docker exec <api-container-id> sqlite3 /data/linkden.db ".backup /data/backup.db"
docker cp <api-container-id>:/data/backup.db ./linkden-backup-$(date +%Y%m%d).dbOr set up a cron job on the host:
# Add to crontab: daily backup at 2 AM
0 2 * * * docker exec linkden-api sqlite3 /data/linkden.db ".backup /data/backup.db" && cp /var/lib/docker/volumes/linkden-data/_data/backup.db /backups/linkden-$(date +\%Y\%m\%d).dbStep 11: Updating and Redeploying
Automatic Deployments
If you connected a GitHub repository, Coolify can automatically redeploy when you push to a branch:
- Go to your service settings.
- Enable Automatic deployments.
- Select the branch to watch (e.g.,
main).
Manual Redeployments
- Go to the service in the Coolify dashboard.
- Click Redeploy.
Or via the Coolify CLI:
coolify deploy --service linkden-api
coolify deploy --service linkden-webUpdating LinkDen
git pull origin main
# Push to your deployment branch, or manually redeploy in CoolifyDatabase: D1 vs. SQLite File
When running outside Cloudflare, Cloudflare D1 is not available. LinkDen uses Drizzle ORM with SQLite, which means you can use any SQLite-compatible driver:
| Environment | Database | Driver |
|---|---|---|
| Cloudflare Workers | D1 | drizzle-orm/d1 (built-in) |
| Docker / Coolify / Railway | SQLite file | better-sqlite3 or @libsql/client |
| Turso (managed libSQL) | Remote SQLite | @libsql/client |
To switch from D1 to a file-based SQLite driver, update the database initialization in packages/db/src/index.ts:
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
const sqlite = new Database(process.env.DATABASE_URL?.replace("file:", "") || "./data/linkden.db");
export const db = drizzle(sqlite);Or for libSQL (compatible with Turso for remote databases):
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
const client = createClient({
url: process.env.DATABASE_URL || "file:./data/linkden.db",
});
export const db = drizzle(client);Troubleshooting
Container exits immediately
Check the container logs in Coolify (click on the service, then Logs). Common causes:
- Missing required environment variables
- Port already in use
- Syntax error in Dockerfile
API cannot connect to the database
- Verify the
/datadirectory exists inside the container. - Check that the persistent volume is mounted correctly.
- Ensure
DATABASE_URLis set tofile:/data/linkden.db.
Web app shows "Failed to fetch" or API errors
- Verify
NEXT_PUBLIC_API_URLwas set correctly at build time. - Check that
CORS_ORIGINon the API matches the web app's domain. - Ensure the API service is running and healthy.
SSL certificate not provisioning
- DNS records must point to the Coolify server IP.
- Ports 80 and 443 must be open.
- Wait a few minutes for DNS propagation.
- Check Coolify logs for Let's Encrypt errors.
"Database is locked" errors
SQLite allows only one write at a time. Under heavy load:
- Enable WAL mode:
PRAGMA journal_mode=WAL; - Consider using Turso (managed libSQL) for better concurrency.