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_URL | PostgreSQL connection string |
SESSION_SECRET | Secret for signing session cookies |
APP_PORT | Host-published port for Astro SSR (Docker only, default: 4322) |
API_PORT | Host-published port for Fastify API (Docker only, default: 3001) |
S3_BUCKET | S3 bucket name |
S3_REGION | S3 region |
S3_ENDPOINT | S3 endpoint URL (for MinIO, R2, etc.) |
S3_ACCESS_KEY | S3 access key |
S3_SECRET_KEY | S3 secret key |
BACKUP_S3_PREFIX | S3 key prefix for database backups |
Production deployment
The recommended production setup:
- Build the Docker image:
docker build -t underlay . - Create a
.envwith production values (or use SOPS encryption) - 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:
- Push to
main - Build Docker image → push to GHCR
- 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.