Ory Hydra is an open-source OAuth 2.0 Authorization Server and OpenID Connect (OIDC) provider. Unlike identity platforms that bundle user management, Hydra delegates authentication to a separate login and consent application that you control, and it handles the OAuth 2.0 authorization code flow, client credentials flow, token introspection, and token revocation through an API-first architecture that separates the authorization protocol from identity storage. This guide walks through deploying Ory Hydra on a Linux server using Docker Compose with PostgreSQL, Nginx, and a Java-based login and consent application. By the end, you'll have a working OAuth 2.0/OIDC server with its admin API bound to the loopback interface and a full authorization code flow you can test end to end.
Before you begin, you need a Linux-based server with at least 2 CPU cores and 4 GB of RAM as a non-root user with sudo privileges, Docker and Docker Compose installed, and a DNS A record pointing to your server's IP address (for example, hydra.example.com).
1. Set Up the Directory Structure, Configuration, and Environment Variables
Ory Hydra reads its configuration from a YAML file and reads database credentials and secrets from environment variables at runtime. The directory structure separates the Hydra configuration, the Nginx reverse proxy configuration, and the TLS certificate storage into distinct paths.
1. Create the project directory with all required subdirectories:
$ mkdir -p ~/ory-hydra/{config/nginx,data/{postgres,certbot/conf}}
config/ stores the Hydra configuration file and the Nginx server configuration, data/postgres/ persists PostgreSQL database files across container restarts, and data/certbot/conf/ stores the Let's Encrypt TLS certificate files.
2. Navigate to the project directory:
$ cd ~/ory-hydra
3. Clone the Java reference login and consent application into the project directory:
$ git clone https://github.com/ardetrick/ory-hydra-refrence-java.git reference-app
If you have your own application that implements the Hydra login and consent protocol, skip this step and replace ./reference-app in the Docker Compose file with your application's source directory.
4. Generate a system secret for signing tokens and encrypting database records (Hydra requires at least 16 characters). Run this command twice:
$ openssl rand -hex 16
Use the first output as YOUR_SYSTEM_SECRET and the second as YOUR_PAIRWISE_SALT (the OIDC pairwise subject identifier salt). The system secret is written into hydra.yml for reference, but the SECRETS_SYSTEM environment variable defined in .env takes precedence at runtime because Docker Compose passes it directly to the Hydra container — replace YOUR_SYSTEM_SECRET in both files with the same generated value to keep them consistent.
5. Create the Hydra configuration file:
$ nano config/hydra.yml
6. Add the following content, replacing hydra.example.com with your domain name, YOUR_SYSTEM_SECRET with the first generated value, and YOUR_PAIRWISE_SALT with the second:
serve:
public:
base_url: https://hydra.example.com/
cors:
enabled: true
allowed_origins:
- https://hydra.example.com
allowed_methods:
- POST
- GET
- PUT
- DELETE
allowed_headers:
- Authorization
- Content-Type
exposed_headers:
- Content-Type
allow_credentials: true
admin:
base_url: http://127.0.0.1:4445/
urls:
self:
issuer: https://hydra.example.com/
login: https://hydra.example.com/login
consent: https://hydra.example.com/consent
logout: https://hydra.example.com/logout
secrets:
system:
- YOUR_SYSTEM_SECRET
oidc:
subject_identifiers:
supported_types:
- public
- pairwise
pairwise:
salt: YOUR_PAIRWISE_SALT
strategies:
access_token: opaque
ttl:
login_consent_request: 30m
access_token: 1h
refresh_token: 720h
id_token: 1h
auth_code: 10m
log:
level: info
format: text
leak_sensitive_values: false
serve.public sets the public API base URL and restricts CORS to your domain. serve.admin declares the admin API base URL — the actual network restriction is enforced by the Docker Compose port binding (127.0.0.1:4445:4445), which limits the admin API to the loopback interface. urls tells Hydra where to redirect users during login and consent. secrets.system signs access tokens and encrypts sensitive database records; changing it invalidates all existing tokens. strategies.access_token: opaque means tokens are random strings validated through the introspection endpoint (the alternative, jwt, allows stateless validation but cannot be revoked before expiry). ttl configures token lifetimes, and log disables sensitive value exposure in log output. The admin API base_url uses http://127.0.0.1:4445/ intentionally — the admin API accepts and rejects login and consent requests without authentication and must never be exposed through the public reverse proxy.
7. Create the Nginx configuration file:
$ nano config/nginx/default.conf
8. Add the following content, replacing all instances of hydra.example.com with your actual domain name:
server {
listen 80;
server_name hydra.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name hydra.example.com;
ssl_certificate /etc/letsencrypt/live/hydra.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hydra.example.com/privkey.pem;
resolver 127.0.0.11 valid=30s;
# Callback page for the OAuth 2.0 authorization code flow
location = /callback {
root /etc/nginx/html;
try_files /callback.html =404;
}
# Login and consent app routes
location ~ ^/(login|consent|logout|demo) {
set $login_consent hydra:8080;
proxy_pass http://$login_consent;
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;
}
# Hydra public OAuth 2.0 and OIDC endpoints
location / {
set $hydra hydra:4444;
proxy_pass http://$hydra;
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;
}
}
The first server block redirects all HTTP traffic to HTTPS. The second listens on 443 with TLS and contains three location blocks: /callback serves the static callback HTML page directly; ^/(login|consent|logout|demo) routes to the login and consent app on port 8080; and / routes everything else to the Hydra public API on port 4444, covering the authorization endpoint, token endpoint, revocation endpoint, and the /.well-known/openid-configuration discovery document. Because all services run on the same Docker network, Nginx resolves the hydra hostname through Docker's internal DNS — the resolver 127.0.0.11 valid=30s directive is required when using variables in proxy_pass directives. The X-Forwarded-Proto header ensures Hydra recognizes that the original client connection uses HTTPS, which is required for secure redirect generation and cookie handling.
9. Create the callback page that displays the authorization code after a successful OAuth 2.0 flow:
$ nano config/nginx/callback.html
10. Add the following content:
<!DOCTYPE html>
<html>
<head>
<title>OAuth2 Callback</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.4.0/vue.global.prod.min.js"></script>
</head>
<body>
<div id="app">
<div v-if="error">
<h2>Authorization Failed</h2>
<p><strong>Error:</strong> {{ error }}</p>
<p><strong>Reason:</strong> {{ errorDesc }}</p>
</div>
<div v-else-if="code">
<h2>Authorization Successful</h2>
<p><strong>Code:</strong> <code>{{ code }}</code></p>
<pre>{{ curlCmd }}</pre>
</div>
<div v-else>
<h2>No code or error received.</h2>
</div>
</div>
<script>
const { createApp } = Vue;
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const error = params.get("error");
const errorDesc = params.get("error_description");
const origin = window.location.origin;
createApp({
data() {
return {
code,
error,
errorDesc,
curlCmd: code ? [
"curl -X POST " + origin + "/oauth2/token \\",
" -H 'Content-Type: application/x-www-form-urlencoded' \\",
" -d 'grant_type=authorization_code' \\",
" -d 'code=" + code + "' \\",
" -d 'redirect_uri=" + origin + "/callback' \\",
" -d 'client_id=YOUR_CLIENT_ID' \\",
" -d 'client_secret=YOUR_CLIENT_SECRET'"
].join("\n") : ""
};
}
}).mount("#app");
</script>
</body>
</html>
The callback page reads the code and error query parameters from the URL and renders the result using Vue. On a successful flow, it displays the authorization code and a ready-to-run curl command to exchange it for tokens; on a failed flow, it displays the error and reason returned by Hydra.
11. Create the environment variables file:
$ nano .env
12. Add the following content, replacing EXAMPLE_DB_PASSWORD with a strong, unique database password and YOUR_SYSTEM_SECRET with the same value you placed in config/hydra.yml:
HYDRA_VERSION=v26.2.0
POSTGRES_USER=hydra
POSTGRES_PASSWORD=EXAMPLE_DB_PASSWORD
POSTGRES_DB=hydradb
SECRETS_SYSTEM=YOUR_SYSTEM_SECRET
LOG_LEVEL=info
2. Deploy with Docker Compose
Docker Compose manages all services as a single deployment unit. Certbot runs once as a standalone container to obtain the initial TLS certificate before Nginx starts, which requires port 80 to be free at that point.
1. Create the Docker Compose file:
$ nano docker-compose.yml
2. Add the following content:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./data/postgres:/var/lib/postgresql/data
networks:
- hydra-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
hydra-migrate:
image: oryd/hydra:${HYDRA_VERSION}
environment:
- DSN=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
command: migrate sql -e --yes
depends_on:
postgres:
condition: service_healthy
networks:
- hydra-network
restart: on-failure
deploy:
resources:
limits:
cpus: '0.50'
memory: 256M
hydra:
image: oryd/hydra:${HYDRA_VERSION}
ports:
- "127.0.0.1:4445:4445"
environment:
- DSN=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
- SECRETS_SYSTEM=${SECRETS_SYSTEM}
- LOG_LEVEL=${LOG_LEVEL}
command: serve all --config /etc/config/hydra/hydra.yml
volumes:
- ./config:/etc/config/hydra
depends_on:
hydra-migrate:
condition: service_completed_successfully
networks:
- hydra-network
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
hydra-login-consent:
build:
context: ./reference-app
dockerfile_inline: |
FROM gradle:8-jdk21 AS build
WORKDIR /app
COPY . .
ENV GRADLE_OPTS="-Xmx256m -Xms64m -Dfile.encoding=UTF-8"
RUN gradle bootJar --no-daemon
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/reference-app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "-Xmx200m", "-Xms64m", "app.jar"]
depends_on:
- hydra
network_mode: "service:hydra"
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.50'
memory: 512M
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./config/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./data/certbot/conf:/etc/letsencrypt:ro
- ./config/nginx/callback.html:/etc/nginx/html/callback.html:ro
depends_on:
- hydra
- hydra-login-consent
networks:
- hydra-network
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.50'
memory: 128M
networks:
hydra-network:
driver: bridge
postgres stores OAuth 2.0 clients, authorization codes, tokens, and consent records, with a pg_isready health check gating dependent services. hydra-migrate runs database migrations once and exits with code 0; hydra waits for that exit before starting. hydra runs serve all, starting the public API on port 4444 and the admin API on port 4445 — the admin port is bound to 127.0.0.1 on the host, restricting it to the loopback interface. The SECRETS_SYSTEM environment variable overrides the value in hydra.yml at runtime. hydra-login-consent builds the Java reference app via a multi-stage Gradle and Eclipse Temurin build and, using network_mode: "service:hydra", shares Hydra's network namespace to reach the admin API at 127.0.0.1:4445. nginx terminates TLS and is the only service that exposes ports to the host. All services connect to a shared hydra-network bridge network, and each includes a deploy.resources.limits block capping CPU and memory. The PostgreSQL DSN uses sslmode=disable because the connection travels over the internal Docker bridge network; if you move PostgreSQL to a separate host, change this to sslmode=require and configure TLS on the PostgreSQL server.
3. Run Certbot as a Docker container in standalone mode to get a TLS certificate from Let's Encrypt, replacing admin@example.com and hydra.example.com with your own values:
$ docker run --rm -p 80:80 \
-v ~/ory-hydra/data/certbot/conf:/etc/letsencrypt \
certbot/certbot certonly --standalone \
--non-interactive --agree-tos --no-eff-email \
--email admin@example.com \
-d hydra.example.com
4. Check that the certificate files exist, replacing hydra.example.com with your domain name:
$ sudo ls ~/ory-hydra/data/certbot/conf/live/hydra.example.com/
Verify that the output lists fullchain.pem, privkey.pem, chain.pem, cert.pem, and README.
5. Create the certificate renewal script:
$ nano ~/ory-hydra/renew-cert.sh
6. Add the following content:
#!/usr/bin/env bash
set -euo pipefail
cd ~/ory-hydra
docker compose stop nginx
docker run --rm -p 80:80 \
-v ~/ory-hydra/data/certbot/conf:/etc/letsencrypt \
certbot/certbot renew --non-interactive
docker compose start nginx
Save and close the file, then make it executable:
$ chmod +x ~/ory-hydra/renew-cert.sh
7. Open the crontab editor:
$ crontab -e
If this is the first time you run crontab -e, the system prompts you to select an editor from a numbered list. Enter the number corresponding to /bin/nano or your preferred editor.
8. Add the following line to run the renewal script at 3:00 a.m. on the 1st and 15th of every month:
0 3 1,15 * * /bin/bash ~/ory-hydra/renew-cert.sh >> ~/ory-hydra/renew-cert.log 2>&1
Let's Encrypt certificates expire after 90 days, so running the job twice a month keeps the certificate updated. Certbot only renews certificates that expire within 30 days, so running the script twice a month is safe, and a transient failure on the 1st is retried on the 15th.
9. Start all services in detached mode:
$ docker compose up -d
Wait about 30 seconds for the database to initialize and migrations to complete.
10. Check the status of all containers:
$ docker compose ps -a
Verify that postgres, hydra, hydra-login-consent, and nginx all show Up, and that hydra-migrate shows Exited (0).
11. Check the Hydra logs to confirm that the server started without errors:
$ docker compose logs hydra | grep "Setting up http server"
Verify that the output contains two lines showing Hydra listening on 0.0.0.0:4444 and 0.0.0.0:4445. If you see database connection errors, wait a few seconds and restart the Hydra container:
$ docker compose restart hydra
3. Verify the Deployment and Test the Authorization Code Flow
Hydra exposes a public API on port 4444 for OAuth 2.0 and OIDC requests, and an admin API on port 4445 for client management, accessible only from the server through the loopback port binding.
1. Test the Hydra admin API health endpoint from the server:
$ curl -s http://127.0.0.1:4445/health/alive
A successful response returns {"status":"ok"}.
2. Test the public API through the Nginx HTTPS reverse proxy, replacing hydra.example.com with your domain name:
$ curl -s https://hydra.example.com/health/alive
3. Verify that the OpenID Connect discovery document is accessible, replacing hydra.example.com with your domain name:
$ curl -s https://hydra.example.com/.well-known/openid-configuration
Verify that the response contains a JSON document with the issuer, authorization_endpoint, token_endpoint, and jwks_uri fields.
4. Register a new OAuth 2.0 client using the Hydra CLI inside the running container, replacing hydra.example.com with your domain name:
$ docker compose exec hydra hydra create oauth2-client \
--endpoint http://127.0.0.1:4445 \
--format json \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile \
--redirect-uri https://hydra.example.com/callback \
--token-endpoint-auth-method client_secret_post \
--name "My Test App"
The response is a JSON object containing the registered client. Copy the client_id and client_secret values. The offline_access scope requests a refresh token alongside the access token; the openid scope triggers Hydra to issue an ID token identifying the authenticated user.
5. Open a web browser and navigate to the authorization URL below, replacing both instances of hydra.example.com with your domain name and CLIENT_ID with the client_id from the previous step:
https://hydra.example.com/oauth2/auth?response_type=code&client_id=CLIENT_ID&redirect_uri=https://hydra.example.com/callback&scope=openid+offline_access+profile&state=random-state-value
Hydra checks for an active session and, finding none, redirects the browser to the login page.
6. On the login page, enter the demo credentials below and click Log in:
-
email:
foo@bar.com -
password:
password
The login app accepts the challenge through the Hydra admin API and redirects the browser to the consent page.
7. On the consent page, review the requested scopes and click Allow access. The consent app accepts the challenge and Hydra redirects the browser to https://hydra.example.com/callback with the authorization code in the code query parameter.
8. The callback page displays the authorization code and a ready-to-run curl command to exchange it for tokens. Replace YOUR_CLIENT_ID and YOUR_CLIENT_SECRET in the displayed command with the values from client registration, then run the command from your server terminal. A successful response returns a JSON object containing an access_token, refresh_token, and id_token. Copy the access_token value.
9. Introspect the access token to confirm that it is active, replacing ACCESS_TOKEN with the access token from the previous step:
$ curl -s -X POST http://127.0.0.1:4445/admin/oauth2/introspect \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'token=ACCESS_TOKEN'
A valid, active token returns a JSON response with "active": true, the sub field containing the authenticated user's identifier, the scope field listing the granted scopes, and token metadata including iss, iat, and exp.
Next Steps
- Replace the Java reference login and consent app with your own implementation, backed by your real user directory.
- Configure additional OAuth 2.0 clients for each application that needs tokens from this server.
- Switch
strategies.access_tokentojwtif you need stateless validation and can accept the revocation trade-off. - Set up centralized logging and alerting on the Hydra and Nginx containers for production monitoring.
For the full guide with additional tips, visit the original article on Vultr Docs.
Top comments (0)