Hellenic Identity

Self-hosting

Run it on your own server.

Self-hosted means the identity server runs on a machine you pay for, and your users, their password hashes and their sessions live in a database you own. Nobody else can read them, and nobody prices you per user. This page takes you from an empty folder to a running server on four kinds of host, with the exact commands.

What you need

  • A machine. One small VM, or a container on a cloud platform.
  • A PostgreSQL database. Managed or in a container beside the server.
  • A domain, with TLS in front of the server. Caddy does this in two lines.
  • A backup of two things: the database and config.yml.

What you get

  • Sign-in on your domain, with tokens your services verify locally.
  • Your data, in your database, on your terms.
  • No per-user pricing and no vendor between you and your users.
  • The admin panel at id.hellenic.dev, which talks to your server directly and stores nothing anywhere else.

What you do not need

  • Redis. Caching is optional and off by default.
  • A migration tool. The server creates its own tables on every start.
  • A second service. One binary, one database, that is the whole thing.
  • To tell the server about the panel. id.hellenic.dev is allowed out of the box.

Not a developer?

The shortest path is Docker Compose on a VPS: one file, three commands, and a cheap virtual machine from any provider. If you would rather have help, paste the prompt below into an AI assistant and let it walk you through each step. And if you would rather someone else ran it, write to contact@hellenic.dev.

Step 1

Write config.yml.

The server reads one YAML file at start. The wizard writes it for you in seven steps, generates the two Ed25519 key pairs and the encryption key in your browser, and sends nothing anywhere. Download the file and keep it: it is the one thing besides the database you cannot recreate.

Three values are yours to own:

ConnectionString
Where the database is. Step 2 covers it.
EncryptionKey
16 random bytes as 32 hex characters. The SDKs and the panel encrypt every password with it before sending, so it is shared with your apps and never with anyone else.
Auth.Keys
Two key pairs. IRIS_AUTH_ACCESS signs access tokens. IRIS_AUTH_REFRESH is listed as optional in the server, but the admin token is a refresh token, so treat it as required.
Open the config wizard
config.yml, trimmedEvery key, in the Iris docs
Server:
  Addr: ":8080"
  Name: "Hellenic Identity Server"
  LogLevel: info
Identity:
  ConnectionString: host=db.example.com port=5432 user=identity password=CHANGE_ME dbname=identity sslmode=require
  EncryptionKey: 16_RANDOM_BYTES_AS_32_HEX     # the wizard generates this
  UsernameIsEmail: true
  Schema:
    - Name: firstname
      Type: text
      Params: ["1", "155"]
      Required: true
  Cache:
    Disable: true                             # Dragonfly or Redis, later, if you want it
  Auth:
    Headers: ["Authorization", "X-Authorization"]
    Keys:
      - ID: IRIS_AUTH_ACCESS                  # required
        Alg: EdDSA
        MaxAge: 2h
        Private: |
          -----BEGIN PRIVATE KEY-----
          ...the wizard generates the pair...
          -----END PRIVATE KEY-----
        Public: |
          -----BEGIN PUBLIC KEY-----
          ...
          -----END PUBLIC KEY-----
      - ID: IRIS_AUTH_REFRESH                 # the admin token is a refresh token, so this one too
        Alg: EdDSA
        MaxAge: 720h
        Private: |
          ...
        Public: |
          ...

A real file is longer: the user schema, cache and cost settings, the cookie keys. The wizard's Review step shows all of it.

Step 2

Give it a PostgreSQL database.

Any PostgreSQL 13 or newer. The server needs three things from it: a database that already exists, a role that can create a schema and the pgcrypto extension, and a connection over TLS unless the database is on the same private network.

Everything else it does itself. On every start it creates the tables it is missing and one GIN index per attribute you marked as indexed, then checks the schema and refuses to start if it disagrees. There is no migration tool to run and no SQL file to apply.

The connection string
host=db.example.com port=5432 user=identity password=CHANGE_ME dbname=identity sslmode=require pool_max_conns=12 pool_min_conns=1
  • Azure Database for PostgreSQL. Flexible Server. Add pgcrypto to the azure.extensions parameter first, or the first boot fails on CREATE EXTENSION.
  • Cloud SQL. Connect through the socket Cloud Run mounts: host=/cloudsql/PROJECT:REGION:INSTANCE, no public IP.
  • Neon, Supabase, or any managed host. Paste their connection details in. Keep sslmode=require.
  • A container beside the server. postgres:17 in the same Compose file, sslmode=disable on the private network. The Compose path below does this.

Step 3

Write two files.

The server is a Go package, github.com/kataras/iris/v14/auth/identity, and the program that runs it is yours: about forty lines that read the config, connect, mount the routes and listen. That is on purpose. You can add a health route, a metrics endpoint or your own pages next to it. The Dockerfile builds that program into one static binary.

main.go
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/kataras/iris/v14"
	"github.com/kataras/iris/v14/auth/identity"
	"gopkg.in/yaml.v3"
)

// The shape of config.yml, as the wizard writes it.
type configuration struct {
	Server struct {
		iris.Options       `yaml:",inline"`
		iris.ServerOptions `yaml:",inline"`
	} `yaml:"Server"`
	Identity identity.Options `yaml:"Identity"`
}

func main() {
	file := os.Getenv("IDENTITY_CONFIG") // where the platform mounted it
	if file == "" {
		file = "config.yml"
	}
	data, err := os.ReadFile(file)
	if err != nil {
		log.Fatalf("read %s: %v", file, err)
	}
	var config configuration
	if err := yaml.Unmarshal(data, &config); err != nil {
		log.Fatalf("parse %s: %v", file, err)
	}
	// Cloud Run and App Service choose the port; Addr in the file is the fallback.
	if port := os.Getenv("PORT"); port != "" {
		config.Server.Addr = ":" + port
	}

	app := iris.New(config.Server.Options)
	server := identity.New(config.Identity) // connects to PostgreSQL, creates the tables
	defer server.Close()

	// Registers the admin panel as a client, or finds it by name on a restart,
	// and signs its token. Once you have stored the pair, delete these lines:
	// an admin token in a cloud log is a token in a cloud log.
	admin, err := server.AddServerApplication(context.Background(),
		"Admin panel", "https://id.hellenic.dev", "https://id.hellenic.dev/icon-512.png")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Encryption Key: %s\nServer Application Token: %s\n", admin.EncryptionKey, admin.Token)

	// Before the identity routes, so a load balancer can probe it without a token.
	app.Get("/health", func(ctx iris.Context) { ctx.WriteString("ok") })
	app.Router("/", server)
	app.Run(config.Server.ServerOptions)
}
go.mod, once
go mod init example.com/identity
go get github.com/kataras/iris/v14@latest gopkg.in/yaml.v3@latest
go mod tidy
Dockerfile
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /identity .

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /identity /identity
# config.yml is mounted at run time, never copied in: it holds
# the database password and both private keys, and images travel.
EXPOSE 8080
ENTRYPOINT ["/identity"]

Two lines in main.go earn their place. PORT is what Cloud Run and App Service set; the program listens there when it is set and on the file's Addr when it is not. IDENTITY_CONFIG is where the platform mounted the file. The image never contains config.yml: it holds the database password and both private keys, and images get pushed to registries and pulled onto laptops.

Step 4

Pick where it runs.

Four paths, each complete. Every one ends with the server printing the encryption key and the admin token, which is step 5.

  1. 1

    Docker Compose on a VPS

    One file, three commands, no cloud account. The shortest path.

  2. 2

    Azure Container Apps

    The platform the author runs in production. Managed TLS and scaling.

  3. 3

    Google Cloud Run

    Deploy from source, pay per request, Cloud SQL beside it.

  4. 4

    A binary on a box

    go build, a systemd unit, Caddy in front. Nothing else.

1. Docker Compose on a VPS

A virtual machine from any provider, with Docker installed and ports 80 and 443 open. Postgres, the server and Caddy run as three containers; Caddy gets a certificate from Let's Encrypt on its own once the domain points at the machine. One small VM (1 vCPU, 1 GB of memory) is enough to start.

compose.yml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: identity
      POSTGRES_PASSWORD: CHANGE_ME
      POSTGRES_DB: identity
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U identity"]
      interval: 5s
      retries: 10

  identity:
    build: .
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - ./config.yml:/config.yml:ro
    environment:
      IDENTITY_CONFIG: /config.yml
    restart: unless-stopped

  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    restart: unless-stopped

volumes:
  pgdata:
  caddy_data:
Caddyfile
id.example.com {
	reverse_proxy identity:8080
}
Start it
# config.yml: host=db user=identity password=CHANGE_ME
#              dbname=identity sslmode=disable
docker compose up -d --build
docker compose logs identity | grep -E "Encryption Key|Server Application Token"

The database is on the Compose network, so the connection string uses host=db and sslmode=disable; nothing leaves the machine unencrypted because nothing leaves the machine. Back up the pgdata volume and config.yml.

2. Azure Container Apps

This is the path the author runs in production. The registry builds the image, so you need no Docker locally, and the platform terminates TLS, gives the app a hostname and scales it. Azure Database for PostgreSQL Flexible Server holds the data; the one thing to know is that its extensions are allowlisted, and the server needs pgcrypto on its first boot.

Azure CLI
# Names and a region. The registry name is letters and digits only.
RG=identity-rg; LOC=westeurope; ACR=identityacr$RANDOM; ENV=identity-env; APP=identity
az group create --name $RG --location $LOC

# PostgreSQL Flexible Server, plus the pgcrypto allowlist the server needs on first boot.
az postgres flexible-server create --resource-group $RG --name $APP-db --location $LOC \
  --admin-user identity --admin-password 'CHANGE_ME' --tier Burstable --sku-name Standard_B1ms \
  --version 17 --public-access 0.0.0.0 --database-name identity
az postgres flexible-server parameter set --resource-group $RG --server-name $APP-db \
  --name azure.extensions --value pgcrypto

# Build the image in the cloud (no local Docker), from the directory with the Dockerfile.
az acr create --resource-group $RG --name $ACR --sku Basic --admin-enabled true
az acr build --registry $ACR --image identity:1 .

# The app: TLS and a hostname come from the platform; config.yml is a secret mounted as a file.
az containerapp env create --resource-group $RG --name $ENV --location $LOC
az containerapp create --resource-group $RG --name $APP --environment $ENV \
  --image $ACR.azurecr.io/identity:1 --registry-server $ACR.azurecr.io \
  --ingress external --target-port 8080 --min-replicas 1 \
  --secrets "config=$(cat config.yml)" --secret-volume-mount /secrets \
  --env-vars IDENTITY_CONFIG=/secrets/config
az containerapp logs show --resource-group $RG --name $APP --tail 50   # the key and the token

# Later, a new version:
az acr build --registry $ACR --image identity:2 . && az containerapp update --resource-group $RG --name $APP --image $ACR.azurecr.io/identity:2

The connection string in config.yml points at $APP-db.postgres.database.azure.com with sslmode=require. Add your own domain with az containerapp hostname add once the app answers on its azurecontainerapps.io address. The --public-access 0.0.0.0 above opens the database to Azure services only; tighten it to the app's outbound IP when you are done.

3. Google Cloud Run

Cloud Run builds from the source directory, sets PORT, and mounts the Cloud SQL socket into the container, so the database needs no public IP and no certificate. You pay while requests are being served; a quiet server costs close to nothing, and a cold start adds a moment to the first request after a lull.

gcloud
PROJECT=$(gcloud config get-value project); REGION=europe-west1

# Cloud SQL for PostgreSQL. The database and the user are created here; the server makes the tables.
gcloud sql instances create identity-db --database-version=POSTGRES_17 --region=$REGION --tier=db-f1-micro
gcloud sql databases create identity --instance=identity-db
gcloud sql users create identity --instance=identity-db --password='CHANGE_ME'

# In config.yml, connect through the socket Cloud Run mounts (no public IP, no TLS to configure):
#   host=/cloudsql/$PROJECT:$REGION:identity-db user=identity password=CHANGE_ME dbname=identity

# config.yml as a secret, mounted as a file. Then deploy straight from the source directory.
gcloud secrets create identity-config --data-file=config.yml
gcloud run deploy identity --source . --region=$REGION --allow-unauthenticated \
  --add-cloudsql-instances=$PROJECT:$REGION:identity-db \
  --set-secrets=/secrets/config.yml=identity-config:latest \
  --set-env-vars=IDENTITY_CONFIG=/secrets/config.yml
gcloud run services logs read identity --region=$REGION --limit=50   # the key and the token

The --allow-unauthenticated flag lets browsers and your apps reach the server; the identity routes are protected by their own tokens. Map your domain with gcloud run domain-mappings create or put a load balancer in front.

4. A binary on a box

No containers. Build the program on the machine, or copy the binary over, and let systemd keep it running. Caddy in front gets the certificate and forwards to :8080. Postgres can be a local install or a managed one.

identity.service
# /etc/systemd/system/identity.service
[Unit]
Description=Hellenic Identity server
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
User=identity
WorkingDirectory=/opt/identity
ExecStart=/opt/identity/identity
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/identity

[Install]
WantedBy=multi-user.target
Caddyfile
# /etc/caddy/Caddyfile: TLS from Let's Encrypt, the server on :8080 behind it.
id.example.com {
	reverse_proxy 127.0.0.1:8080
}
Build and start
# On the box, with Go installed, from the directory with main.go and config.yml.
CGO_ENABLED=0 go build -ldflags="-s -w" -o /opt/identity/identity .
sudo cp config.yml /opt/identity/config.yml && sudo chmod 600 /opt/identity/config.yml
sudo systemctl enable --now identity
journalctl -u identity | grep -E "Encryption Key|Server Application Token"

Create the identity user first (useradd --system --home /opt/identity identity). The unit reads config.yml from its working directory, so no environment variable is needed here.

Step 5

First boot.

The server connects, creates its tables, registers the admin panel as an OAuth 2.0 client and prints two lines:

Encryption Key: 9f3a...c2e1 (32 hex characters)
Server Application Token: eyJhbGciOiJFZERTQSIs...

Open the admin panel, enter your server's URL, the token and the key, and you are in. The panel talks to your server from your browser and keeps the token in local storage on your machine; nothing about your users passes through id.hellenic.dev.

A restart reuses the client

AddServerApplication looks the client up by name before creating it, so restarts do not pile up clients. The token is signed again on each boot; earlier ones stay valid until the client's version is bumped from the panel.

Then stop printing it

A token in a cloud log is a token anyone with log access holds. Once the pair is stored, delete the three AddServerApplication lines from main.go and redeploy. The client stays in the database.

The panel is already allowed

The server's CORS layer allows https://id.hellenic.dev by default and local origins in development. Your own front ends are added from the panel's CORS page, stored in the database, and applied without a restart.

Now integrate

The SDKs page in the panel writes the Go and C# snippets with your server's URL and token filled in. Your services verify tokens against /.well-known/jwks.json and never call back.

Ask an AI to do it with you

Paste this, pick a platform, answer its questions.

The prompt names the stack, points the assistant at the files on this page, and tells it to stop before anything costs money. It works in any assistant that can read a URL.

Plexon AI is a desktop assistant for Windows, macOS and Linux, built by the author of Iris and of this server. It ships the Iris skill built in: the v14 API, the auth SDK and this identity server, in nineteen reference documents it loads while it writes your code, so it already knows the config keys, the connection string and what the server prints on first boot. Turn the skill on from the Skills panel, or install the Software Developer persona, which enables it together with the rest of its engineering tooling. Download Plexon AI.

The prompt
I am self-hosting Hellenic Identity, an OAuth 2.0 identity server written in Go on the Iris web framework (module github.com/kataras/iris/v14, package auth/identity). It needs one PostgreSQL database and reads a config.yml at startup; it creates its own tables on first boot, so there is no migration step. I already have my config.yml from https://id.hellenic.dev/create-server/config/ and the main.go and Dockerfile from https://id.hellenic.dev/create-server/.

I want to run it on: [Docker Compose on a VPS | Azure Container Apps | Google Cloud Run | a Linux VM with systemd]. My domain will be: [id.example.com].

Walk me through it one step at a time and wait for me after each step. Ask me for anything you need before you assume it. Keep config.yml out of the container image and out of git. Stop and check with me before any command that creates a cloud resource or costs money. At the end, tell me where the server printed the encryption key and the server application token, and how to enter them at https://id.hellenic.dev/connect.

Questions

Before you run it.

What does the server need to run?

One Go binary, one PostgreSQL database, and something in front of it that terminates TLS: Caddy, nginx, or the cloud platform itself. Dragonfly or Redis is optional and only caches responses; both production deployments the author runs have it switched off.

Do I have to run database migrations?

No. On every start the server creates its schema, its tables (users, user_groups, oauth2_clients, origins, metrics and the rest) and one GIN index per indexed attribute, all with IF NOT EXISTS. The database itself must already exist, and the role needs to be able to create the schema and the pgcrypto extension.

Do I need Redis?

No. Leave Cache.Disable set to true, which is the wizard’s default. Enable it later, pointing at a Dragonfly or Redis address, if you want responses cached.

Where do the admin token and the encryption key come from?

The server prints both on first boot. main.go calls AddServerApplication, which registers the panel as an OAuth 2.0 client and signs a token for it; on a restart it finds the same client by name, so nothing piles up. Enter the pair at /connect and then keep it somewhere safe.

What happens if I lose the encryption key?

No user data is lost. Stored passwords are bcrypt hashes and stay valid. The key is the shared secret the SDKs and the panel use to encrypt a password before sending it, and the server application token is sealed with it too, so a new key means a restart to print a new token, and every SDK and the panel get the new key. Back the key up with config.yml.

How do I back it up?

Two things: the PostgreSQL database and config.yml. The database holds every user, group, client and usage record; the file holds the keys that make the tokens and passwords readable. A restore is those two put back.

Does the admin panel need any CORS setup?

No. The server allows https://id.hellenic.dev by default, so the hosted panel reaches a fresh server as soon as it is up. Your own front ends are added from the panel’s CORS page, stored in the database and applied without a restart.

Can I run it on Windows?

Yes. It is a Go program, so it builds and runs on Windows, macOS and Linux, and go build produces one file with no runtime to install. The containers on this page are Linux because that is what the platforms run.

Start with the file.

Seven steps, keys included, nothing sent anywhere. Then come back to step 4.

Open the config wizard