Why deploy Keycloak on your VPS
Where some IdPs aim for simplicity, Keycloak aims for completeness: multi-tenant isolation through realms, LDAP/Active Directory directory federation, brokering to external providers (Google, GitHub, another OIDC), full theming of login pages and fine-grained authorization. For an agency managing several clients or an IT department with compliance requirements, this level of control is decisive. Self-hosting on a VPS spares you the per-user costs of managed IdP offerings and keeps the entire identity base under your jurisdiction, a critical point for sensitive data you would rather not entrust to a third party.
The strengths of a self-hosted Keycloak
- Multiple realms to fully isolate several clients or environments on a single instance.
- Native, complete support for OpenID Connect, OAuth2 and SAML 2.0.
- LDAP / Active Directory federation to reuse an existing directory without migration.
- Identity brokering: sign-in via Google, GitHub or any external IdP in a few clicks.
- Fully customizable login pages (themes, languages, per-realm branding).
- Fine-grained authentication policies: conditional MFA, session duration, role-based constraints.
Prerequisites for this deployment
Keycloak runs on the JVM (Quarkus since v17+) and remains more resource-hungry than a lightweight IdP. Plan for a VPS with at least 2 vCPUs and 4 GB of RAM, ideally 6 GB if you expect load or several active realms. An external PostgreSQL database is strongly recommended in production (never use the embedded H2 database beyond testing). Docker and Docker Compose, a dedicated domain (sso.mydomain.com) and a reverse proxy handling SSL round out the prerequisites, because Keycloak expects to be told that it is served behind an HTTPS proxy.
Deploy Keycloak with PostgreSQL
Define Keycloak and PostgreSQL in Compose
In your
docker-compose.yml, declare thepostgresservice (imagepostgres:16) and thekeycloakservice (imagequay.io/keycloak/keycloak). Connect Keycloak to the database viaKC_DB=postgres,KC_DB_URL,KC_DB_USERNAMEandKC_DB_PASSWORD.Configure production mode and the proxy
Launch with the
startcommand (notstart-dev). SetKC_HOSTNAME=sso.mydomain.comandKC_PROXY_HEADERS=xforwardedso that Keycloak generates correct URLs behind the reverse proxy. Define the initial admin viaKC_BOOTSTRAP_ADMIN_USERNAME/KC_BOOTSTRAP_ADMIN_PASSWORD.Start and complete the build
Run
docker compose up -d. On first start, Keycloak performs an optimized Quarkus build and then migrates the PostgreSQL schema. Followdocker compose logs -f keycloakuntil the message indicating that the server is listening.Set up the SSL reverse proxy
Route
sso.mydomain.comto port8080of the Keycloak container via Caddy, Traefik or Nginx, with a Let's Encrypt certificate. Be sure to pass theX-Forwarded-*headers, otherwise login redirects will break.Create a realm and a client
Log in to the admin console, create a dedicated realm (never the
masterrealm for your apps), then an OIDC client. Set your application'sValid redirect URIsand retrieve theClient IDand secret in the Credentials tab.Connect an application and test the flow
Configure your app with the discovery URL
https://sso.mydomain.com/realms/mon-realm/.well-known/openid-configuration. Create a test user in the realm, start a login and verify the token issuance in the console's Sessions.
Pre-optimized image: separate the build from startup
By default, the start command checks at startup whether a Quarkus build phase is needed and runs it if so: this is what lengthens the first start, and it is what writes into the installation tree. The vendor documentation recommends the opposite for a container: run kc.sh build while building the image, then start with start --optimized. The --optimized parameter tells Keycloak it is running in an already optimized image and can skip that check. Build options, such as the database type (db) or enabling health probes (health-enabled), are frozen into the image: passed again at startup with the same value they are ignored, and with a different value they cause an error and require a new build. Secrets, database password included, belong to configuration options and stay supplied at runtime.
Build and run a pre-optimized image
Write a two-stage Dockerfile
The first stage starts from
quay.io/keycloak/keycloaknamedbuilder, setsENV KC_DB=postgresandENV KC_HEALTH_ENABLED=true, then runsRUN /opt/keycloak/bin/kc.sh build. Pin a precise version number rather thanlatest, so that every rebuild stays reproducible.Copy the result into the final image
The second stage starts again from the same image and runs
COPY --from=builder /opt/keycloak/ /opt/keycloak/. Place your custom themes and providers before the build: they are then taken into account by the optimization. If you add files withCOPY, add--chown=keycloak:keycloakso that Keycloak can write into the folders concerned.Start with the optimized option
In Compose, replace the command with
start --optimized. Behind a reverse proxy that terminates TLS, addKC_HTTP_ENABLED=true. TheKC_HOSTNAMEandKC_PROXY_HEADERSvariables and the database credentials remain runtime configuration.Wire up the health probes
With
KC_HEALTH_ENABLED=trueset at build time, the/health/started,/health/liveand/health/readyendpoints are served on management port9000, separate from the application port8080. Do not publish this port on the Internet: use it only for the Composehealthcheckor your monitoring.
Keycloak or Authentik: which to choose?
Scroll the table
| Criterion | Keycloak | Authentik |
|---|---|---|
| Target use case | Enterprise SSO, multi-tenant, compliance | Self-hosting, homelab, agencies |
| Protocols | OIDC, OAuth2, SAML, LDAP | OIDC, OAuth2, SAML, LDAP, forward auth |
| Forward auth (proxy) | Not native (via extensions) | Native and central to the tool |
| Resource footprint | High (JVM, 4 GB+ RAM) | Moderate (2-4 GB RAM) |
| Multi-realm / tenants | Excellent (isolated realms) | Tenants supported, less advanced |
| Flow customization | Themes and Java SPI | Visual step-based flows |
| Learning curve | Steep, very complete | More accessible |
| Directory federation | Mature (LDAP/AD) | Good (LDAP) |
Back up and migrate without losing a realm
Keycloak's state lives in two places: the PostgreSQL database, and your configuration files (themes, providers, import files). The reference backup is a scheduled pg_dump, copied off the VPS; test the restore on a throwaway instance, because a backup that has never been restored remains a hypothesis. Realm export complements this setup without replacing it: kc.sh export --dir <folder>, or --realm my-realm for a single realm, with the --users option to choose the user export strategy. The documentation specifies that the server must not be running during the export, that consistency is only guaranteed with all nodes stopped, and that events, persisted sessions and revoked tokens are not exported. Beyond 50,000 users, export to a directory rather than to a single file. On import, start --import-realm reads the .json files of the /opt/keycloak/data/import folder and skips a realm that already exists.
Operations checklist
- Pinned version — fix the image version number, read the release notes before changing major branch and rebuild the optimized image on every version upgrade.
- Backed-up database — scheduled
pg_dump, copy off the VPS, restore proven on a throwaway instance. - Bounded memory — always set a memory limit on the container: the JVM computes its heap from it, with 70% maximum and 50% initial by default.
- Probes monitored — follow
/health/readyon the unpublished port9000from your monitoring. - Proxy headers under control — the reverse proxy must overwrite the
X-Forwarded-*headers received from the client rather than append to them, and must never be bypassed. - Events enabled — log sign-ins and administration actions in each realm, to know who signed in and who changed what.
Troubleshooting: startup fails with a read-only filesystem
Hardening a container often means read_only: true in Compose or readOnlyRootFilesystem: true in Kubernetes. With Keycloak, the topic has been open since April 2022 in issue keycloak/keycloak #11286 ("Keycloak does not work with Docker read_only: true"), and it was still active at the end of October 2026. What is established on the project side: a Keycloak contributor reproduced the failure on version 19.0.2 in September 2022, the issue is classified as an enhancement with the "help wanted" label, and it was added to a repository project board in September 2026. What is not: the official documentation describes no read-only mode to date (the page on containers does not mention it), and no maintainer has validated a workaround. The solutions below come from discussion participants; they worked for them, on their versions, with no guarantee after a version upgrade. On 26 October 2026, a contributor offered to write the missing documentation after verifying these workarounds themselves.
Error messages to recognize
sed: couldn't open temporary file /opt/jboss/keycloak/bin/seduHoQM0: Read-only file system— message from the original report, on the former WildFly image (version 16.1.1 in the issue): ased -iin the startup script cannot create its temporary file. On the Quarkus distribution the symptoms differ, see the following lines.KC-SERVICES0075: Failed to get theme request: java.lang.RuntimeException: Temporary directory /opt/keycloak/bin/../data/tmp does not exist and it was not possible to create it.— reported during an upgrade to 20.0.1: the server starts, then fails on the first page that serves a theme. A participant reported that a writable volume mounted on/opt/keycloak/data/tmpfixes this case.- Slow startup or write failure in
/opt/keycloak/lib/quarkus— a participant compared the container before and after startup withdocker diff: three folders change,/tmp,/opt/keycloak/dataand/opt/keycloak/lib/quarkus. The last one holds the build output, whichstartregenerates unless the image is pre-optimized. - Read-only
datafolder after aCOPY— a participant reports that forgetting--chown=keycloak:keycloakwhen copying a realm file leaves/opt/keycloak/dataowned by root, hence not writable by Keycloak. - Copying
/opt/keycloakinto a volume at startup — an init container workaround, reported on Kubernetes: it works, but several participants dread having to retest it on every version upgrade.
Reported workaround in Docker Compose
Start from the pre-optimized image
Reuse the image built above.
kc.sh buildruns at build time:/opt/keycloak/lib/quarkusis no longer rewritten at startup, which is done withstart --optimized.Declare the read-only filesystem
Add
read_only: trueto thekeycloakservice of the Compose file. With no other change, the container fails: this is the behavior described by the issue.Provide the writable directories
Mount a
tmpfson/tmpand a temporary volume on/opt/keycloak/data. In the report relying on a pre-optimized image, these two directories were enough; on a non-optimized image, other participants added/opt/keycloak/lib/quarkus.Put back what was in data
A temporary volume starts empty: what the image placed in this folder, including
data/import, is no longer visible there. If you import realms with--import-realm, mount their folder separately, read-only.Check by opening a login page
Run
docker compose up -dthendocker compose logs -f keycloak, then open the sign-in page: the first theme request is what made some setups fail. Finish withdocker diffto list what the container actually wrote.
Read-only: a hardening measure the vendor does not document
Keep this mode for environments where a policy requires it, such as a security audit or a cluster rule, and accept the cost: an undocumented configuration to retest on every version upgrade, which several participants of the issue dread. On a single VPS, other measures give a clearer gain for less upkeep: Linux capabilities dropped (cap_drop: [ALL]), the no-new-privileges option, unpublished management ports and the PostgreSQL database on an internal Docker network.
In production, never serve Keycloak with the embedded H2 database and never expose the master realm to the public: create a dedicated realm per application or per client, and reserve master for administration. To harden the instance, disable free registration, enable brute force detection in Realm Settings > Security Defenses, and provision everything via realm import files (--import-realm) so you version your configuration rather than clicking through the UI.