Identity & Access
    Docker

    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

    ProjectKeycloak by Red Hat
    LicenseApache 2.0
    Version26.6.0
    Recommended PlanRamNode VPS 2 GB (4 GB for production)
    OSUbuntu 22.04 / 24.04 LTS
    StackDocker Compose (Keycloak, PostgreSQL 16, Nginx)
    Estimated Setup Time25–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 CasePlanvCPURAMStorage
    Dev/TestingVPS 2GB12 GB30 GB SSD
    Small Production (< 500 users)VPS 4GB24 GB60 GB SSD
    Medium Production (500–5000 users)VPS 8GB48 GB100 GB SSD
    1

    Initial Server Setup

    Update and install
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y curl git ufw
    Configure firewall
    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw enable
    2

    Install Docker and Docker Compose

    Install Docker
    curl -fsSL https://get.docker.com | sudo sh
    sudo usermod -aG docker $USER

    Log out and back in, then verify:

    Verify
    docker --version
    docker compose version
    3

    Set Up the Project Directory

    Create directory and .env
    mkdir -p ~/keycloak-deploy && cd ~/keycloak-deploy
    .env
    # 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.com

    Generate random passwords: openssl rand -base64 24

    Lock permissions
    chmod 600 .env
    4

    Create the Docker Compose File

    docker-compose.yml
    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: bridge

    Keycloak binds only to 127.0.0.1:8080 — not directly accessible from the internet.

    5

    Configure Nginx as a Reverse Proxy

    Create directories
    mkdir -p nginx/conf.d certbot/www certbot/conf

    Start with HTTP-only for the ACME challenge:

    nginx/conf.d/keycloak.conf (initial)
    server {
        listen 80;
        server_name auth.yourdomain.com;
    
        location /.well-known/acme-challenge/ {
            root /var/www/certbot;
        }
    
        location / {
            return 301 https://$host$request_uri;
        }
    }
    6

    Obtain an SSL Certificate

    Start Nginx and get cert
    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.com
    Copy certificates
    sudo 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 || true

    Update Nginx config with HTTPS:

    nginx/conf.d/keycloak.conf (final)
    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";
        }
    }
    7

    Launch the Full Stack

    Start all services
    docker compose up -d
    Monitor startup
    docker compose logs -f keycloak

    Wait 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.

    8

    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.

    9

    Production Hardening

    Automate Certificate Renewal

    Set up a cron job to renew certificates twice daily:

    Cron entry
    0 3,15 * * * /home/YOUR_USER/keycloak-deploy/renew-certs.sh >> /var/log/certbot-renew.log 2>&1

    Enable 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

    Backup script
    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.gz
    Daily cron
    0 2 * * * /home/YOUR_USER/keycloak-deploy/backup-db.sh >> /var/log/keycloak-backup.log 2>&1
    10

    Monitoring and Health Checks

    Check health
    curl -s http://localhost:8080/health | python3 -m json.tool
    View metrics
    curl -s http://localhost:8080/metrics

    These endpoints are only on 127.0.0.1:8080 — not publicly accessible. Integrate with Prometheus/Grafana or Uptime Kuma.

    Updating Keycloak

    Update procedure
    cd ~/keycloak-deploy
    ./backup-db.sh
    docker compose pull keycloak
    docker compose up -d keycloak
    docker compose logs -f keycloak

    Keycloak 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.