Deploy Hasura GraphQL Engine on a VPS
Instant, real-time GraphQL and REST APIs over PostgreSQL with built-in authorization, event triggers, and remote schemas — no boilerplate CRUD code required.
At a Glance
| Project | Hasura GraphQL Engine v2.48.x |
| License | Apache 2.0 |
| Recommended Plan | RamNode Cloud VPS 2GB or higher (4GB for heavier workloads) |
| OS | Ubuntu 22.04 / 24.04 LTS |
| Stack | Docker Compose (Hasura, PostgreSQL 16, Caddy) |
| Default Port | 8080 (proxied via Caddy on 443) |
| Estimated Setup Time | 15–20 minutes |
Prerequisites
- A RamNode VPS with at least 2 GB RAM, 1 vCPU, 25 GB SSD
- A domain or subdomain pointed to your VPS IP (e.g.,
hasura.yourdomain.com) - SSH access with a non-root sudo user
- Basic familiarity with Docker and the Linux command line
Initial Server Setup
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget git ufw apt-transport-https ca-certificates gnupg lsb-releaseConfigure the Firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enablePort 8080 stays closed — all traffic routes through Caddy on port 443.
Set Hostname and Timezone
sudo hostnamectl set-hostname hasura-vps
sudo timedatectl set-timezone America/ChicagoInstall Docker and Docker Compose
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-pluginsudo usermod -aG docker $USER
newgrp dockerdocker --version
docker compose versionCreate the Environment File
mkdir -p ~/hasura && cd ~/hasuraopenssl rand -base64 32Run that command twice for two different values, then create the .env file:
POSTGRES_USER=hasura
POSTGRES_PASSWORD=REPLACE_WITH_GENERATED_SECRET_1
POSTGRES_DB=hasura
HASURA_GRAPHQL_ADMIN_SECRET=REPLACE_WITH_GENERATED_SECRET_2
HASURA_DOMAIN=hasura.yourdomain.comchmod 600 .envCreate the Docker Compose File
services:
postgres:
image: postgres:16
restart: unless-stopped
volumes:
- pg_data:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- hasura-net
graphql-engine:
image: hasura/graphql-engine:v2.48.12
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
PG_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_GRAPHQL_ADMIN_SECRET}
HASURA_GRAPHQL_DEV_MODE: "false"
HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
HASURA_GRAPHQL_LOG_LEVEL: warn
HASURA_GRAPHQL_ENABLE_TELEMETRY: "false"
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: anonymous
HASURA_GRAPHQL_CORS_DOMAIN: "https://${HASURA_DOMAIN}"
networks:
- hasura-net
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- graphql-engine
networks:
- hasura-net
volumes:
pg_data:
caddy_data:
caddy_config:
networks:
hasura-net:
driver: bridgeKey decisions: Dev mode is false for production. Admin secret is required. Telemetry is disabled. Caddy handles automatic HTTPS via Let's Encrypt.
Configure the Caddy Reverse Proxy
{$HASURA_DOMAIN} {
reverse_proxy graphql-engine:8080
header {
-Server
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /data/access.log {
roll_size 10mb
roll_keep 5
}
}
}Caddy automatically provisions a Let's Encrypt certificate, strips the Server header, and adds security headers to all responses.
Deploy the Stack
cd ~/hasura
docker compose up -ddocker compose logs -fdocker compose psYou should see postgres, graphql-engine, and caddy all running.
Access the Hasura Console
Navigate to https://hasura.yourdomain.com/console and enter the admin secret from your .env file.
Quick Verification
Create a test table from the Data tab, then run this mutation from the API tab:
mutation {
insert_test_items(objects: [
{ name: "First item" },
{ name: "Second item" }
]) {
returning {
id
name
}
}
}PostgreSQL Tuning for Small VPS
# Memory
shared_buffers = 512MB
effective_cache_size = 1GB
work_mem = 8MB
maintenance_work_mem = 128MB
# WAL
wal_buffers = 16MB
checkpoint_completion_target = 0.9
max_wal_size = 1GB
# Connections
max_connections = 100
# Query Planner
random_page_cost = 1.1
effective_io_concurrency = 200Mount the config in your postgres service:
postgres:
image: postgres:16
restart: unless-stopped
volumes:
- pg_data:/var/lib/postgresql/data
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
command: postgres -c config_file=/etc/postgresql/postgresql.confdocker compose down
docker compose up -dAutomated Backups
mkdir -p ~/hasura/backups
cat > ~/hasura/backup.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail
BACKUP_DIR="$HOME/hasura/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/hasura_${TIMESTAMP}.sql.gz"
RETENTION_DAYS=14
source "$HOME/hasura/.env"
docker exec hasura-postgres-1 pg_dumpall -U "${POSTGRES_USER}" | gzip > "${BACKUP_FILE}"
find "${BACKUP_DIR}" -name "hasura_*.sql.gz" -mtime +${RETENTION_DAYS} -delete
echo "[$(date)] Backup completed: ${BACKUP_FILE}"
SCRIPT
chmod +x ~/hasura/backup.sh(crontab -l 2>/dev/null; echo "0 3 * * * $HOME/hasura/backup.sh >> $HOME/hasura/backups/backup.log 2>&1") | crontab -Metadata Export and Version Control
curl -L https://github.com/hasura/graphql-engine/raw/stable/cli/get.sh | bashcd ~/hasura
hasura init hasura-project --endpoint https://hasura.yourdomain.com --admin-secret YOUR_ADMIN_SECRET
cd hasura-project
hasura metadata exportCommit the metadata/ directory to Git. Restore with hasura metadata apply.
Resource Monitoring
sudo wget https://github.com/bcicen/ctop/releases/download/v0.7.7/ctop-0.7.7-linux-amd64 -O /usr/local/bin/ctop
sudo chmod +x /usr/local/bin/ctopRun ctop for a live view of container CPU, memory, and network usage.
Health Endpoint
curl -s https://hasura.yourdomain.com/healthzA 200 OK confirms the engine is running. Point an uptime monitor at /healthz for alerting.
Updating Hasura
Update the image tag in docker-compose.yml, then pull and restart:
cd ~/hasura
docker compose pull graphql-engine
docker compose up -d graphql-engineAlways take a database backup before upgrading.
Security Checklist
- Admin secret set to a strong, unique value
- Dev mode set to
false - Port 8080 not exposed in UFW
- CORS restricted to your application's domain
- TLS active with auto-renewing certificates
- PostgreSQL credentials are randomly generated
- Database backups running and verified
- Metadata exported and stored in version control
Troubleshooting
- Cannot connect to PostgreSQL — Verify postgres is healthy with
docker compose ps. Check.envcredentials match. - 502 Bad Gateway from Caddy — The graphql-engine isn't ready yet. Check
docker compose logs graphql-engine. - SSL certificate not issuing — Confirm DNS A record and ports 80/443 are open in UFW.
- High memory usage — Reduce
shared_buffersandwork_mem. Monitor withctopordocker stats.
