Deploy Keycloak on a VPS
Production-ready open-source identity and access management with SSO, social login, MFA, and full OAuth 2.0 / OpenID Connect / SAML 2.0 support.
At a Glance
| Project | Keycloak by Red Hat |
| License | Apache 2.0 |
| Version | 26.6.0 |
| Recommended Plan | RamNode VPS 2 GB (4 GB for production) |
| OS | Ubuntu 22.04 / 24.04 LTS |
| Stack | Docker Compose (Keycloak, PostgreSQL 16, Nginx) |
| Estimated Setup Time | 25–35 minutes |
Prerequisites
- A RamNode VPS with at least 2 GB RAM (4 GB recommended for production)
- Ubuntu 22.04 or 24.04 LTS
- A DNS A record pointing to your VPS IP (e.g.,
auth.yourdomain.com) - SSH access with a sudo-enabled user
Recommended Plans
| Use Case | Plan | vCPU | RAM | Storage |
|---|---|---|---|---|
| Dev/Testing | VPS 2GB | 1 | 2 GB | 30 GB SSD |
| Small Production (< 500 users) | VPS 4GB | 2 | 4 GB | 60 GB SSD |
| Medium Production (500–5000 users) | VPS 8GB | 4 | 8 GB | 100 GB SSD |
Initial Server Setup
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl git ufwsudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableInstall Docker and Docker Compose
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USERLog out and back in, then verify:
docker --version
docker compose versionSet Up the Project Directory
mkdir -p ~/keycloak-deploy && cd ~/keycloak-deploy# PostgreSQL
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=CHANGE_ME_STRONG_DB_PASSWORD
# Keycloak Admin
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=CHANGE_ME_STRONG_ADMIN_PASSWORD
# Domain
KC_HOSTNAME=auth.yourdomain.comGenerate random passwords: openssl rand -base64 24
chmod 600 .envCreate the Docker Compose File
services:
postgres:
image: postgres:16-alpine
container_name: keycloak-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- keycloak-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:26.6.0
container_name: keycloak-app
restart: unless-stopped
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
KC_DB_USERNAME: ${POSTGRES_USER}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
KC_HOSTNAME: ${KC_HOSTNAME}
KC_HOSTNAME_STRICT: "true"
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
JAVA_OPTS_KC_HEAP: "-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50 -XX:MaxHeapFreeRatio=30"
command: start
depends_on:
postgres:
condition: service_healthy
networks:
- keycloak-net
ports:
- "127.0.0.1:8080:8080"
nginx:
image: nginx:alpine
container_name: keycloak-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
networks:
- keycloak-net
depends_on:
- keycloak
volumes:
pgdata:
networks:
keycloak-net:
driver: bridgeKeycloak binds only to 127.0.0.1:8080 — not directly accessible from the internet.
Configure Nginx as a Reverse Proxy
mkdir -p nginx/conf.d certbot/www certbot/confStart with HTTP-only for the ACME challenge:
server {
listen 80;
server_name auth.yourdomain.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}Obtain an SSL Certificate
docker compose up -d nginx
sudo apt install -y certbot
sudo certbot certonly --webroot -w ./certbot/www \
-d auth.yourdomain.com \
--agree-tos --no-eff-email \
-m you@yourdomain.comsudo cp -rL /etc/letsencrypt/live ./certbot/conf/ 2>/dev/null || true
sudo cp -rL /etc/letsencrypt/archive ./certbot/conf/ 2>/dev/null || true
sudo cp /etc/letsencrypt/options-ssl-nginx.conf ./certbot/conf/ 2>/dev/null || true
sudo cp /etc/letsencrypt/ssl-dhparams.pem ./certbot/conf/ 2>/dev/null || trueUpdate Nginx config with HTTPS:
server {
listen 80;
server_name auth.yourdomain.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name auth.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/auth.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
location / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}Launch the Full Stack
docker compose up -ddocker compose logs -f keycloakWait for Keycloak 26.6.0 on JVM (powered by Quarkus) started, then visit https://auth.yourdomain.com/admin and log in with the bootstrap credentials.
Post-Deployment Configuration
Create a New Realm
Reserve the master realm for Keycloak admin only. Click the realm dropdown → Create realm → enter a name (e.g., myapp).
Create a Client
In your realm → Clients → Create client. Set type to OpenID Connect, enter a Client ID, enable Client authentication for confidential apps, set redirect URIs, and note the client secret.
Create a Test User
Users → Add user. Set username and email. Go to Credentials tab to set a password.
Production Hardening
Automate Certificate Renewal
Set up a cron job to renew certificates twice daily:
0 3,15 * * * /home/YOUR_USER/keycloak-deploy/renew-certs.sh >> /var/log/certbot-renew.log 2>&1Enable Brute Force Protection
Navigate to Realm settings → Security defenses → Brute force detection. Set max login failures to 5, wait increment to 60s, max wait to 900s.
Configure Password Policies
Realm settings → Authentication → Password policy: minimum length 12, uppercase 1, digits 1, special characters 1, not recently used 3.
Database Backups
docker compose -f /home/$USER/keycloak-deploy/docker-compose.yml exec -T postgres \
pg_dump -U keycloak keycloak | gzip > ~/keycloak-backups/keycloak_$(date +%Y%m%d_%H%M%S).sql.gz0 2 * * * /home/YOUR_USER/keycloak-deploy/backup-db.sh >> /var/log/keycloak-backup.log 2>&1Monitoring and Health Checks
curl -s http://localhost:8080/health | python3 -m json.toolcurl -s http://localhost:8080/metricsThese endpoints are only on 127.0.0.1:8080 — not publicly accessible. Integrate with Prometheus/Grafana or Uptime Kuma.
Updating Keycloak
cd ~/keycloak-deploy
./backup-db.sh
docker compose pull keycloak
docker compose up -d keycloak
docker compose logs -f keycloakKeycloak handles database schema migrations automatically on startup. Always back up the database before upgrading.
Troubleshooting
Database connection errors
Verify PostgreSQL is healthy: docker compose ps. Check .env credentials match across services.
"HTTPS required" error
Ensure KC_PROXY_HEADERS is set to xforwarded and Nginx includes the X-Forwarded-Proto header.
Admin console loads slowly
Increase Nginx proxy buffer sizes. The admin console serves large JavaScript bundles.
High memory usage
Adjust JAVA_OPTS_KC_HEAP values or upgrade to a 4 GB plan.
