Self-Hosting

Underlay is designed to be self-hosted. You need three things: a Node.js runtime, a PostgreSQL database, and S3-compatible object storage.

Requirements

  • Node.js ≥ 22.12
  • PostgreSQL 16+
  • S3-compatible storage — AWS S3, MinIO, Cloudflare R2, etc.
  • Docker (recommended) — or run directly with Node

Quick start with Docker

Clone the repo and run:

git clone https://github.com/knowledgefutures/underlay.git
cd underlay
./dev.sh

This starts Postgres, MinIO (S3), and the Underlay app in development mode. The dev script auto-creates a .env.dev from defaults if one doesn't exist.

Environment variables

DATABASE_URLPostgreSQL connection string
SESSION_SECRETSecret for signing session cookies
APP_PORTHost-published port for Astro SSR (Docker only, default: 4322)
API_PORTHost-published port for Fastify API (Docker only, default: 3001)
S3_BUCKETS3 bucket name
S3_REGIONS3 region
S3_ENDPOINTS3 endpoint URL (for MinIO, R2, etc.)
S3_ACCESS_KEYS3 access key
S3_SECRET_KEYS3 secret key
BACKUP_S3_PREFIXS3 key prefix for database backups

Production deployment

The recommended production setup:

  1. Build the Docker image: docker build -t underlay .
  2. Create a .env with production values (or use SOPS encryption)
  3. Run with docker compose up -d

The production docker-compose.yml includes:

  • postgres — PostgreSQL 16 with a named volume
  • app — Runs migrations, then starts Astro SSR + Fastify API
  • cron — Scheduled tasks (database backups)

Secrets management

We use SOPS with age encryption. Encrypted .env.enc files are committed to the repo; plaintext .env files are gitignored.

# Generate a keypair
age-keygen -o key.txt

# Add the public key to .sops.yaml, then:
npm run secrets:encrypt       # .env → .env.enc
npm run secrets:decrypt       # .env.enc → .env
npm run secrets:encrypt:dev   # .env.dev → .env.dev.enc
npm run secrets:decrypt:dev   # .env.dev.enc → .env.dev

CI/CD

The included GitHub Actions workflow (.github/workflows/deploy.yml) handles the full pipeline:

  1. Push to main
  2. Build Docker image → push to GHCR
  3. SSH to server → pull image → decrypt secrets → docker compose up

Required GitHub secrets: SSH_PRIVATE_KEY, SSH_HOST, SSH_USER, GHCR_USER, GHCR_TOKEN.

Backups

The cron container runs daily Postgres backups to S3:

# Manual backup
npm run tool:backup

# Backups are stored at:
# s3://{bucket}/{BACKUP_S3_PREFIX}{timestamp}/underlay.sql.gz

Reverse proxy

Put Caddy, nginx, or Cloudflare in front. The app exposes port 4321 (Astro SSR) and port 3000 (Fastify API). In production, the Astro frontend proxies /api to Fastify internally, so you only need to expose port 4321.