# How to Run Keycloak in a Sandbox Behind an HTTPS URL

> Run the official Keycloak image with start, --hostname set to the sandbox's https hosted URL, --proxy-headers xforwarded and --http-enabled true, then host port 8080 with sbx host. Without the hostname setting, Keycloak builds http:// links, so the admin console fails on mixed content and tokens carry the wrong issuer.

Source: https://sandboxer.com/guides/run-keycloak-in-a-sandbox. Updated 2026-10-27.

**Short answer:** Run the official Keycloak image with start, --hostname set to the sandbox's https hosted URL, --proxy-headers xforwarded and --http-enabled true, then host port 8080 with sbx host. Without the hostname setting, Keycloak builds http:// links, so the admin console fails on mixed content and tokens carry the wrong issuer.

## What we ran

Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB RAM, 12 GB disk, $0.018/hour) with Keycloak 26.4.7 from `quay.io/keycloak/keycloak:26.4`. Secrets are replaced with `<token>`, `<password>` and `<secret>`. Times are single runs unless the table says otherwise.

| Check | Result |
| - | - |
| `sbx new --type small` | 3.2 s to ready |
| Keycloak start (`start`, production profile) | `started in 13.017s` the first time, about 7 s after that |
| Memory | 565 MiB for the container |
| Admin console with no hostname settings | Loads, then shows `somethingWentWrong` (mixed content) |
| Admin console with `--hostname` | Signed in, 0 browser console errors |
| `client_credentials` token request from a laptop | 200, median 0.194 s over 20 runs |
| Token `iss` claim | `https://<subdomain>-8080.box.sandboxer.com/realms/demo`, signature verified against the JWKS |
| `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.3 s, `memoryRestored: true`, next token 200 |

## 1. Create the sandbox and host port 8080 first

You need the hosted URL before Keycloak starts, because it goes into `--hostname`. A hosted port with nothing listening yet is fine:

```bash
sbx new --name keycloak --type small --no-env
id=sb_ycojzw9amt    # the id sbx new printed; its subdomain was prime-smart-elm
sbx host $id 8080 --title Keycloak > host.json
```

```text
{"ok":true,"type":"port.hosted","sandboxId":"sb_ycojzw9amt","port":8080,"url":"https://prime-smart-elm-8080.box.sandboxer.com?_token=<token>","access":"private","isProtected":true,"localhost":false}
```

The URL is `https://<subdomain>-8080.box.sandboxer.com`. The subdomain stays the same across stop and resume, so the Keycloak setting below stays valid. The URL is private: without the token or the `__sbx_auth` cookie it sets, the proxy answers `401` and Keycloak never sees the request. See [Hosting](/docs/hosting).

## 2. What goes wrong with the defaults

We first ran Keycloak the way its [container guide](https://www.keycloak.org/server/containers) starts a test server, with `start-dev` and no hostname settings. Through the hosted URL, with the cookie:

```bash
B=https://prime-smart-elm-8080.box.sandboxer.com
C="Cookie: __sbx_auth=<token>"
j() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; }   # tiny JSON reader
curl -s -H "$C" $B/realms/master/.well-known/openid-configuration | j "['issuer']"
curl -s -D - -o /dev/null -H "$C" $B/admin/ | grep -i ^location
curl -s -H "$C" $B/admin/master/console/ | grep authServerUrl
```

```text
http://prime-smart-elm-8080.box.sandboxer.com/realms/master
location: http://prime-smart-elm-8080.box.sandboxer.com/admin/master/console/
        "authServerUrl": "http://prime-smart-elm-8080.box.sandboxer.com",
```

The host name is right, because the proxy passes the `Host` header through. The scheme is wrong. TLS ends at the Sandboxer edge, and Keycloak sees a plain HTTP request on port 8080, so every URL it builds starts with `http://`.

A plain `http://` request to the box domain gets a `308` to `https://`, so a redirect alone recovers. The admin console does not. We loaded it in headless Chromium:

```text
body: Danger alert: somethingWentWrong somethingWentWrongDescription tryAgain
console errors: 5
  Mixed Content: The page at 'https://prime-smart-elm-8080.box.sandboxer.com/admin/master/console/' was loaded over HTTPS, but requested an insecure resource 'http://prime-smart-elm-8080.box.sandboxer.c…
  Framing 'http://prime-smart-elm-8080.box.sandboxer.com/' violates the following Content Security Policy directive: "frame-src 'self'". The request has been blocked.
```

Tokens have the same problem: their `iss` claim is the `http://` URL, so any client that checks the issuer against the `https://` URL rejects them.

## 3. Start Keycloak with the hostname and proxy settings

Docker is preinstalled in every sandbox. This is the command we kept:

```bash
sbx exec $id --timeout 600 "docker run -d --name kc --restart unless-stopped \
  -p 8080:8080 -v kc-data:/opt/keycloak/data \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD='<password>' \
  quay.io/keycloak/keycloak:26.4 start \
  --hostname https://prime-smart-elm-8080.box.sandboxer.com \
  --hostname-backchannel-dynamic true \
  --proxy-headers xforwarded \
  --http-enabled true
until curl -fs -o /dev/null localhost:8080/realms/master; do sleep 0.5; done"
sbx exec $id "docker logs kc 2>&1 | grep -o 'started in [0-9.]*s'"
```

```text
started in 13.017s
```

What each option does here:

| Option | Why |
| - | - |
| `start` | Production profile. `start-dev` relaxes checks you want on. |
| `--hostname https://…-8080.box.sandboxer.com` | A full URL, so Keycloak uses `https` and this exact host for every front-channel link and for the issuer. See [Configuring the hostname](https://www.keycloak.org/server/hostname). |
| `--proxy-headers xforwarded` | Trust the `X-Forwarded-*` headers. With only this flag (no `--hostname`), the issuer already came out as `https://…`, so the Sandboxer proxy sends `X-Forwarded-Proto: https`. See [Configuring a reverse proxy](https://www.keycloak.org/server/reverseproxy). |
| `--http-enabled true` | Production mode refuses plain HTTP otherwise. The edge does TLS. |
| `--hostname-backchannel-dynamic true` | Lets code inside the sandbox talk to `localhost:8080` (section 6). |
| `-v kc-data:/opt/keycloak/data` | Keeps the built-in database across container restarts. |
| `--restart unless-stopped` | Starts Keycloak again whenever Docker starts. |

`--timeout 600` is for the image pull. `sbx exec` stops a command after 30 seconds by default.

The built-in `dev-file` database is fine for a test server. For anything you keep, Keycloak's docs say to use a real database: see [Configuring the database](https://www.keycloak.org/server/db) and our [Postgres guide](/guides/run-postgres-with-pgvector-in-a-sandbox).

With `--hostname` pinned, a forged header can't change the issuer. We sent `X-Forwarded-Host: evil.example` and `X-Forwarded-Proto: http` through the hosted URL, and the issuer stayed `https://prime-smart-elm-8080.box.sandboxer.com/realms/master`.

## 4. The admin console, end to end

Open the full `?_token=` link once, then go to `/admin/`. We scripted that in headless Chromium: open the token link, open `/admin/`, fill in `admin` and the password, click Sign in.

```text
console url: https://prime-smart-elm-8080.box.sandboxer.com/admin/master/console/ ms 5392
title: Keycloak Administration Console
body: You are logged in as a temporary admin user. To harden security, create a permanent admin account and delete the temporary one. … admin Keycloak Current realm Manage realms …
console errors: 0 []
```

The `KC_BOOTSTRAP_ADMIN_*` account is temporary, as the banner says. Create a permanent admin in the console and delete the bootstrap one before you share the URL.

## 5. Request a token through the hosted URL

Every request to a private URL needs the `__sbx_auth` cookie as well as Keycloak's own credentials. Get an admin token from the `master` realm, then create a realm and a confidential client with a service account through the [Admin REST API](https://www.keycloak.org/docs-api/latest/rest-api/index.html):

```bash
curl -s -H "$C" -d grant_type=password -d client_id=admin-cli -d username=admin \
  --data-urlencode 'password=<password>' $B/realms/master/protocol/openid-connect/token | j "['access_token']" > admtok
A="Authorization: Bearer $(cat admtok)"
curl -s -o /dev/null -w '%{http_code}\n' -H "$C" -H "$A" -H 'Content-Type: application/json' \
  -d '{"realm":"demo","enabled":true}' $B/admin/realms
curl -s -o /dev/null -w '%{http_code}\n' -H "$C" -H "$A" -H 'Content-Type: application/json' \
  -d '{"clientId":"billing-worker","publicClient":false,"serviceAccountsEnabled":true,"standardFlowEnabled":false,"directAccessGrantsEnabled":false}' \
  $B/admin/realms/demo/clients
cid=$(curl -s -H "$C" -H "$A" "$B/admin/realms/demo/clients?clientId=billing-worker" | j "[0]['id']")
curl -s -H "$C" -H "$A" $B/admin/realms/demo/clients/$cid/client-secret | j "['value']" > csecret
```

```text
201
201
```

The admin token lives 60 seconds (`expires_in: 60`), so run these together. Now the OAuth 2.0 client credentials request a backend service would make:

```bash
curl -s -H "$C" -u "billing-worker:$(cat csecret)" -d grant_type=client_credentials \
  $B/realms/demo/protocol/openid-connect/token | j "['access_token']" > cctok
```

Decoded, the token's header and claims were:

```text
header {'alg': 'RS256', 'typ': 'JWT'}
{'iss': 'https://prime-smart-elm-8080.box.sandboxer.com/realms/demo', 'azp': 'billing-worker', 'typ': 'Bearer', 'preferred_username': 'service-account-billing-worker'} exp-iat 300
```

We fetched the realm's JWKS from `$B/realms/demo/protocol/openid-connect/certs` and checked the token with PyJWT, requiring that issuer:

```python
import jwt, json
jwks = jwt.PyJWKSet.from_dict(json.load(open("jwks.json")))
tok = open("cctok").read()
key = [k for k in jwks.keys if k.key_id == jwt.get_unverified_header(tok)["kid"]][0]
c = jwt.decode(tok, key.key, algorithms=["RS256"],
               issuer="https://prime-smart-elm-8080.box.sandboxer.com/realms/demo",
               options={"verify_aud": False})
print("verified", c["iss"], c["azp"])
```

```text
verified https://prime-smart-elm-8080.box.sandboxer.com/realms/demo billing-worker
```

Twenty token requests from our laptop took 0.192 s minimum, 0.194 s median and 0.203 s maximum. [Token introspection](https://www.keycloak.org/docs/latest/securing_apps/#_token_introspection_endpoint) at `…/token/introspect` returned `active: true` with the same issuer.

How each layer says no:

| Request | Answer |
| - | - |
| Right client secret, no `__sbx_auth` cookie | `401`, Sandboxer HTML page |
| Cookie, wrong client secret | `401`, `{"error":"unauthorized_client","error_description":"Invalid client or Invalid client credentials"}` |

A JSON error means the request reached Keycloak. An HTML page means the proxy stopped it.

## 6. Apps inside the same sandbox

An app running next to Keycloak should call `localhost:8080`, not the private URL, which it can't open without the cookie. Without `--hostname-backchannel-dynamic`, Keycloak's discovery document sent it out through the hosted URL anyway. From inside the sandbox:

```bash
sbx exec $id "curl -s localhost:8080/realms/demo/.well-known/openid-configuration | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"token_endpoint\"])'"
```

```text
https://prime-smart-elm-8080.box.sandboxer.com/realms/demo/protocol/openid-connect/token
```

With `--hostname-backchannel-dynamic true`, the same request returned:

```text
issuer https://prime-smart-elm-8080.box.sandboxer.com/realms/demo
token_endpoint http://localhost:8080/realms/demo/protocol/openid-connect/token
jwks_uri http://localhost:8080/realms/demo/protocol/openid-connect/certs
authorization_endpoint https://prime-smart-elm-8080.box.sandboxer.com/realms/demo/protocol/openid-connect/auth
```

Back-channel endpoints follow the request, the browser-facing ones stay on the hosted URL, and the issuer doesn't change. A token requested inside the sandbox over `localhost` had `iss: https://prime-smart-elm-8080.box.sandboxer.com/realms/demo`, the same as one requested from the laptop, so both sides validate the same issuer. From the laptop, the issuer and token endpoint stayed on `https://…box.sandboxer.com`.

## 7. Stop, resume and restart

```bash
sbx stop $id --wait
sbx resume $id
sbx exec $id "docker ps --format '{{.Names}} {{.Status}}'"
```

```text
kc Up 34 seconds
```

Stop took 2.2 seconds and resume 1.3 seconds. The resume reported `memoryRestored: true`, so the same Keycloak process kept running, the old cookie still worked and the next token request returned `200` in 0.20 s. The admin console signed in again with no console errors.

A resume boots from disk instead when no machine with the same CPU type is free. Then processes start fresh, and the restart policy and the `kc-data` volume bring Keycloak back with its realms. We didn't force that, but restarting Docker shows the same path:

```bash
sbx exec $id --timeout 180 "sudo systemctl restart docker
until curl -fs -o /dev/null localhost:8080/realms/demo; do sleep 0.5; done
docker ps --format '{{.Names}} {{.Status}}'"
```

```text
kc Up 8 seconds
```

Keycloak answered again 8.6 seconds after the restart, the `demo` realm was still there, and the client secret from before still got a token.

## 8. Clean up

```bash
sbx delete $id --yes
```

After the delete, the hosted URL returned `404`. A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped.

## FAQ

### Why does the Keycloak admin console show "somethingWentWrong" behind the hosted URL?

Keycloak doesn't know it's behind HTTPS, so the console's config points at `http://` URLs and the browser blocks them as mixed content. Start Keycloak with `--hostname https://<subdomain>-8080.box.sandboxer.com` (or at least `--proxy-headers xforwarded`).

### Is `--proxy-headers xforwarded` enough on its own?

In our test it fixed the scheme: the issuer came out as `https://…`. Pin `--hostname` as well, so the issuer is fixed no matter which headers reach Keycloak. With it set, a forged `X-Forwarded-Host` didn't change the issuer.

### Can an app on my laptop use Keycloak while the URL is private?

Yes, if it sends the `__sbx_auth` cookie with every request, as the curl commands above do. Standard OIDC libraries don't add that cookie on their own. For outside clients that you can't configure, host the port with `--public` and rely on Keycloak's own authentication. Switching public and private mints a new token, so old cookies stop working.

### Do I need the `default` size?

Not for a test server. Keycloak ran on `small` and used 565 MiB. Moving to a smaller size isn't supported yet, so start with `small` and resize up on resume if you need to.

### What happens if I rename the subdomain?

The hosted URL changes, and the `--hostname` value no longer matches. Recreate the container with the new URL. Tokens issued before carry the old issuer.

## Related

- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Run Gitea in a sandbox and push to it over HTTPS](/guides/run-gitea-in-a-sandbox)
- [Run Grafana and Prometheus in a sandbox](/guides/run-grafana-and-prometheus-in-a-sandbox)
- [Hosting](/docs/hosting)
