DEV Community

Cover image for Deploying Ory Hydra: An Open-Source OAuth 2.0 and OpenID Connect Server
Sanskriti Harmukh for Vultr

Posted on with Aashish Chaurasiya Originally published at docs.vultr.com

Deploying Ory Hydra: An Open-Source OAuth 2.0 and OpenID Connect Server

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}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Save and close the file, then make it executable:

$ chmod +x ~/ory-hydra/renew-cert.sh
Enter fullscreen mode Exit fullscreen mode

7. Open the crontab editor:

$ crontab -e
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Wait about 30 seconds for the database to initialize and migrations to complete.

10. Check the status of all containers:

$ docker compose ps -a
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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_token to jwt if 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)