How to Deploy Keycloak on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Run Keycloak in production mode on your own VPS — PostgreSQL instead of the dev database, a fixed hostname, TLS at a Caddy reverse proxy, and the temporary admin account replaced.
- A VPS with at least 2 GB RAM (Keycloak's documented floor on this site is 1 GB, plus PostgreSQL and the OS)
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain you can point at the server, such as auth.example.com
- Docker Engine + Compose installed (see the base guide below)
What Keycloak is
Keycloak is an open-source identity and access management server. It handles single sign-on over OpenID Connect and SAML, federates users from LDAP or Active Directory, and brokers logins from external identity providers. It is written in Java on Quarkus, licensed Apache-2.0, and is the most feature-complete self-hosted identity provider in this catalog, which also makes it the heaviest to run and learn.
The catalog's install snippet for Keycloak uses start-dev. That mode is for
evaluation only: it serves plain HTTP, uses an embedded development database, and
relaxes the hostname checks. This guide does the production setup instead:
startinstead ofstart-dev- PostgreSQL as the database
- a fixed public hostname
- HTTP only on the loopback interface, with TLS terminated at Caddy
- forwarded-header parsing turned on, so Keycloak sees the real scheme and client IP
If you don't need SAML, LDAP federation or fine-grained authorization, a lighter tool may suit you better. Authentik vs Keycloak and Keycloak vs Zitadel compare the options.
Server sizing
The catalog lists Keycloak's minimum at 1 GB RAM. That covers the JVM alone.
PostgreSQL, the OS and a reverse proxy come on top, so start with a 2 GB
instance, and use 4 GB if the same box runs anything else. Keycloak's startup is
CPU-heavy: on the first start it also rebuilds its configuration for PostgreSQL,
so expect a minute or two on a small vCPU. Disk use is modest; 20 GB is enough
for the images, the database and a few local backups.
Prepare the server
This guide assumes Docker Engine, the Compose plugin and a ufw firewall are
set up. If they aren't, work through
Docker & Compose on Ubuntu first.
Only SSH and the reverse proxy should be reachable from outside. Keycloak's own ports stay on the loopback interface:
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw status verbose
Paid link — we earn a commission if you shop through it.
Install Keycloak (Docker Compose + PostgreSQL)
Create a project directory:
mkdir -p ~/keycloak && cd ~/keycloak
Generate the database password and the bootstrap admin password once, into an
.env file that only your user can read. The [ -f .env ] || guard stops a
second run from replacing the passwords of a database that already exists:
[ -f .env ] || cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
KC_BOOTSTRAP_ADMIN_PASSWORD=$(openssl rand -hex 16)
EOF
chmod 600 .env
Now write the compose file. Every Keycloak option can be set as a KC_-prefixed
environment variable, which keeps the whole configuration in one file:
cat > docker-compose.yml <<'YAML'
services:
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 5s
retries: 10
keycloak:
image: quay.io/keycloak/keycloak:26.7.4
command: start
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
KC_HOSTNAME: https://auth.example.com
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
ports:
# Loopback only: Caddy is the only way in from outside.
- "127.0.0.1:8080:8080"
# Management interface (health checks). Never publish it publicly.
- "127.0.0.1:9000:9000"
volumes:
pgdata:
YAML
What each Keycloak setting does:
command: startruns production mode. On a standard image, Keycloak applies the build-time options (the PostgreSQL driver, health checks) during the first start. Upstream notes that this makes startup slower than a pre-built "optimized" image. For a single server that trade-off is usually fine.KC_HOSTNAMEis the public URL, scheme included. Keycloak uses it for every link, redirect and token issuer it generates. Replaceauth.example.comwith your real domain before going live.KC_HTTP_ENABLEDis required when TLS ends at the proxy (edge termination). Keycloak only listens on HTTP here, and that port is bound to127.0.0.1.KC_PROXY_HEADERS: xforwardedtells Keycloak to trust theX-Forwarded-*headers Caddy sets. Without it, requests through the proxy that go through origin checks fail with 403 Forbidden.KC_HEALTH_ENABLEDturns on/health/readyand/health/live. They are served on the management port, 9000, not on 8080.
Start the stack:
docker compose up -d
Wait for Keycloak to report ready. The first start builds its configuration and creates the database schema, so this takes a while:
timeout 600 bash -c 'until curl -fsS http://127.0.0.1:9000/health/ready; do sleep 5; done'
A healthy server answers with {"status": "UP", ...}. To confirm the hostname
setting took effect, read the OpenID discovery document for the built-in
master realm. Its issuer should be your public HTTPS URL, not localhost:
curl -s http://127.0.0.1:8080/realms/master/.well-known/openid-configuration | head -c 300; echo
HTTPS + domain
Point an A record for auth.example.com at the server's public IP and wait
for it to resolve. Then put Caddy in front of the loopback port.
Automatic HTTPS with Caddy covers the
install. The site block is:
auth.example.com {
reverse_proxy 127.0.0.1:8080
}
Caddy gets and renews the certificate itself and sends X-Forwarded-For,
X-Forwarded-Proto and X-Forwarded-Host by default. That is exactly what
KC_PROXY_HEADERS: xforwarded expects. Because Keycloak now trusts those
headers, make sure nothing else can reach port 8080 directly. The loopback bind
takes care of that.
If Caddy runs as a container, 127.0.0.1 is the container's own loopback.
In that case, put Caddy in the same compose file, proxy to keycloak:8080, and
remove the host port mapping.
Keycloak's reverse-proxy guide recommends exposing only the paths that clients
need. The admin console under /admin/ should be reachable only from inside
your network. If you manage Keycloak from a fixed address, one way to do that in
Caddy is:
auth.example.com {
@admin_outside {
path /admin*
not remote_ip 203.0.113.10
}
respond @admin_outside 403
reverse_proxy 127.0.0.1:8080
}
Replace 203.0.113.10 with your own IP, or reach the admin console over an SSH
tunnel to 127.0.0.1:8080 instead.
First login and the temporary admin
Open https://auth.example.com/admin/ and sign in as admin with the
KC_BOOTSTRAP_ADMIN_PASSWORD from ~/keycloak/.env. Keycloak marks this
bootstrap account as temporary, and the console shows a banner saying so.
Replace it:
- In the
masterrealm, create a new user, set a strong password under Credentials, and give it theadminrealm role under Role mapping. - Sign out, sign back in as the new user, and delete the temporary
adminaccount. - Remove
KC_BOOTSTRAP_ADMIN_USERNAMEandKC_BOOTSTRAP_ADMIN_PASSWORDfrom the compose file and.env, then rundocker compose up -d. The bootstrap variables only matter when the master realm has no admin, so leaving them in place does nothing useful.
Next, create a separate realm for your applications. The master realm is
for administering Keycloak itself. Your users and OIDC clients belong in a realm
of their own, such as company.
Securing it
- Turn on OTP for administrators. In the
masterrealm, go to Authentication → Required actions and make Configure OTP a default action, or require it through a browser flow. - Turn on brute-force detection in each realm (Realm settings → Security defenses). It is off by default.
- Keep the management port private. Port 9000 serves health endpoints, and
metrics too if you turn them on. It stays on
127.0.0.1here. - Restrict the admin console path at the proxy, as shown above.
- Keep clients strict. Keep each client's Valid redirect URIs exact rather than using wildcards, and use confidential clients wherever the application can keep a secret.
Backups
Everything Keycloak knows is in PostgreSQL: realms, clients, users, credentials and keys. A consistent logical dump is the backup:
cd ~/keycloak
docker compose exec -T postgres pg_dump -U keycloak keycloak | gzip > keycloak-db-$(date +%F).sql.gz
ls -lh keycloak-db-*.sql.gz
Keep .env and docker-compose.yml with the dump. Without the database
password and the exact configuration, a restore takes longer. Copy all of it
off the server. To restore onto a fresh stack, start only postgres, then
pipe the dump back in with
gunzip -c keycloak-db-DATE.sql.gz | docker compose exec -T postgres psql -U keycloak keycloak
before starting Keycloak.
Upgrades
Upgrade Keycloak by changing the pinned tag, never by pulling latest blindly:
- Take a database backup (above).
- Read the upgrade notes for every version between yours and the target. Keycloak publishes a migration guide with each release.
- Edit the
image:line, for example from26.7.4to the next release, and then run:
cd ~/keycloak
docker compose pull
docker compose up -d
Keycloak migrates the database schema automatically on startup. The dump you took first is your rollback: a schema that has been migrated forward can't be used by an older Keycloak.
Troubleshooting
"HTTPS required" when you open the console. The master realm requires
HTTPS for requests from outside the local network. Open Keycloak through the
Caddy HTTPS URL, not http://SERVER_IP:8080, which is also not published.
403 Forbidden or a redirect loop behind the proxy. Either
KC_PROXY_HEADERS is missing, or the proxy isn't sending the forwarded headers.
Caddy sends them by default. Nginx needs proxy_set_header X-Forwarded-Proto,
X-Forwarded-Host and X-Forwarded-For lines.
Redirects go to the wrong host. KC_HOSTNAME is what Keycloak puts in
links, not the host the browser used. Set it to the exact public URL and
recreate the container.
The container restarts during first boot. Run docker compose logs keycloak.
Database connection errors usually mean the .env password changed after
PostgreSQL was first initialized. PostgreSQL keeps the password it was created
with. An OutOfMemoryError means the box is too small for the JVM plus
PostgreSQL.
Health check never goes green. The health endpoints are on port 9000.
A curl to :8080/health/ready returns 404 on current releases.
Verification + next steps
You're done when:
https://auth.example.com/realms/master/.well-known/openid-configurationloads over a valid certificate, with anhttps://issuer;- you can sign in to the admin console as a permanent admin with OTP;
- the temporary bootstrap account is gone;
- a compressed
pg_dumpis stored off the box.
Then create your application realm, register the first OIDC client, and point an app at it. For hosting options sized for a JVM identity server, see Best VPS for Keycloak. If you only need a login screen in front of a few self-hosted apps, Authelia or Pocket ID is a much smaller deployment.