This guide covers deploying Portal to a Hetzner VPS with Cloudflare as a reverse proxy.
Users → Cloudflare (CDN/DDoS/SSL) → Hetzner VPS → Next.js App + PostgreSQL
-
Hetzner VPS with:
- Ubuntu 22.04 LTS or newer
- Minimum 2 CPU cores, 4GB RAM (4 cores, 8GB recommended for production)
- Docker and Docker Compose installed
- SSH access configured
-
Install Docker and Docker Compose:
# Install Docker curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh # Install Docker Compose (plugin) sudo apt-get update sudo apt-get install docker-compose-plugin # Add user to docker group (replace $USER with your username) sudo usermod -aG docker $USER
-
Create deployment directory:
mkdir -p ~/portal cd ~/portal
- Add your domain to Cloudflare
- Point DNS to your VPS IP (A record)
- Enable proxy (orange cloud) for DDoS protection and CDN
- SSL/TLS mode: Set to "Full (strict)"
- Firewall rules: Optionally restrict to Cloudflare IPs only
Configure the following secrets in your GitHub repository settings:
For both staging and production environments:
VPS_HOST: Your Hetzner VPS IP address or domainVPS_USER: SSH username (e.g.,deployorroot)VPS_SSH_KEY: Private SSH key for authentication (generate withssh-keygen -t ed25519)
Optional:
GITHUB_TOKEN: Automatically provided, but can be overridden if needed
Create .env.staging on your VPS:
# Database
DATABASE_URL=postgresql://postgres:postgres@portal-db-staging:5432/portal_staging
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=portal_staging
# BetterAuth
BETTER_AUTH_SECRET=your-staging-secret-key-here
BETTER_AUTH_URL=https://staging.atl.dev
# Sentry (optional)
NEXT_PUBLIC_SENTRY_DSN=your-sentry-dsn
SENTRY_ORG=your-org
SENTRY_PROJECT=portal
SENTRY_AUTH_TOKEN=your-token
SENTRY_RELEASE=staging
# Other integrations
# XMPP_DOMAIN=xmpp.atl.chat
# PROSODY_REST_URL=https://prosody.example.comCreate .env.production on your VPS:
# Database
DATABASE_URL=postgresql://postgres:your-secure-password@portal-db-production:5432/portal
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-secure-production-password
POSTGRES_DB=portal
# BetterAuth
BETTER_AUTH_SECRET=your-production-secret-key-here
BETTER_AUTH_URL=https://atl.dev
# Sentry (optional)
NEXT_PUBLIC_SENTRY_DSN=your-sentry-dsn
SENTRY_ORG=your-org
SENTRY_PROJECT=portal
SENTRY_AUTH_TOKEN=your-token
SENTRY_RELEASE=production
# Other integrations
# XMPP_DOMAIN=xmpp.atl.chat
# PROSODY_REST_URL=https://prosody.example.comSecurity Notes:
- Use strong, unique passwords for production
- Generate secure secrets:
openssl rand -base64 32 - Never commit
.envfiles to git - Store secrets securely (consider using a secrets manager)
- Staging: Automatically deploys on push to
mainbranch - Production: Manual deployment via GitHub Actions workflow dispatch
If you need to deploy manually:
# SSH into your VPS
ssh user@your-vps-ip
# Navigate to deployment directory
cd ~/portal
# Pull latest image
docker pull ghcr.io/allthingslinux/portal:staging-<commit-sha>
# Deploy (single compose.yaml; use profile for staging or production)
export GITHUB_REPOSITORY="allthingslinux/portal"
export GIT_COMMIT_SHA="<commit-sha>"
docker compose --profile staging up -d
# Or for production
docker compose --profile production up -dMigrations run automatically during production deployments. For manual migration:
# On VPS
docker compose --profile production exec portal-app-production pnpm db:migrateBefore production migrations:
- Backup database:
docker compose --profile production exec portal-db-production pg_dump -U postgres portal > backup.sql - Test migrations on staging first
- Review migration files in
drizzle/directory
# Application logs
docker compose --profile production logs -f portal-app-production
# Database logs
docker compose --profile production logs -f portal-db-production
# All logs
docker compose --profile production logs -fThe application includes health check endpoints:
/api/health- Application health status
Check container health:
docker compose --profile production psIf deployment fails or you need to rollback:
# Stop current containers
docker compose --profile production down
# Pull previous image
docker pull ghcr.io/allthingslinux/portal:production-<previous-commit-sha>
# Restart with previous tag
export IMAGE_TAG=production-<previous-commit-sha>
docker compose --profile production up -d-
SSL/TLS:
- Encryption mode: Full (strict)
- Always Use HTTPS: On
- Minimum TLS Version: 1.2
-
Caching:
- Cache static assets (images, CSS, JS)
- Bypass cache for API routes (
/api/*) - Cache level: Standard
-
Firewall Rules:
- Optionally restrict access to Cloudflare IPs only
- Rate limiting for API endpoints
-
Page Rules (optional):
- Cache static assets:
*.atl.dev/_next/static/*→ Cache Everything - Bypass API:
*.atl.dev/api/*→ Bypass Cache
- Cache static assets:
If you want an additional reverse proxy on the VPS:
# Nginx example
server {
listen 80;
server_name atl.dev;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}With Cloudflare in front, this is usually not necessary.
# Check logs
docker compose --profile production logs portal-app-production
# Check container status
docker compose --profile production ps
# Verify environment variables
docker compose --profile production exec portal-app-production env# Check database is running
docker compose --profile production ps portal-db-production
# Test connection
docker compose --profile production exec portal-db-production psql -U postgres -d portal
# Check database logs
docker compose --profile production logs portal-db-productionStaging and production use different ports (see compose.yaml profiles):
- Staging: App on 3001, DB on 5433
- Production: App on 3000, DB on 5432
If you need to change ports, update compose.yaml for the relevant profile.
# Clean up unused images
docker image prune -af
# Clean up old containers
docker container prune -f
# Remove old volumes (careful!)
docker volume prune -f- Firewall: Only allow SSH (22) and HTTP/HTTPS (80/443) from Cloudflare IPs
- SSH: Use key-based authentication, disable password login
- Docker: Run containers as non-root user (already configured)
- Secrets: Never commit secrets, use environment variables
- Updates: Keep VPS and Docker images updated
- Backups: Regular database backups
- Monitoring: Set up alerts for container failures
# Create backup
docker compose --profile production exec portal-db-production \
pg_dump -U postgres portal > backup-$(date +%Y%m%d-%H%M%S).sql
# Restore backup
docker compose --profile production exec -T portal-db-production \
psql -U postgres portal < backup-20240126-120000.sqlSet up a cron job for daily backups:
# Add to crontab (crontab -e)
0 2 * * * cd ~/portal && docker compose --profile production exec -T portal-db-production pg_dump -U postgres portal > /backups/portal-$(date +\%Y\%m\%d).sqlFor higher traffic, consider:
- Horizontal scaling: Multiple app containers behind a load balancer
- Database: Managed PostgreSQL (Hetzner Database, AWS RDS, etc.)
- Caching: Redis for session storage
- CDN: Cloudflare already provides this
Update compose.yaml (e.g. portal-app-production under the production profile) to add more app replicas:
portal-app-production:
deploy:
replicas: 3For issues or questions:
- Check logs first
- Review GitHub Actions workflow runs
- Check Cloudflare analytics for traffic patterns
- Review Sentry for error tracking