All posts

Logto on a VPS: Cognito without AWS

Self-host Logto 1.40.1 with persistent Postgres: OIDC discovery on loopback, dual core/admin ports, stack idle ~254 MiB. Cognito-shaped auth without AWS.

Written by NodeRings Inc.

NodeRings Inc. writes these notes. The company runs the marketplace and the agent that stays on the provider’s side of the hypervisor.

GET /oidc/.well-known/openid-configuration on this lab returned HTTP 200 with "issuer": "http://127.0.0.1:3021/oidc", plus authorization_endpoint, token_endpoint, and jwks_uri under the same host. Logto 1.40.1 had seeded Postgres once (cli db seed --swe) and was listening for core on mapped port 3021 and admin on 3022. Quiet docker stats sat at 222.7 MiB for the app and about 30.8 MiB for Postgres (stack median ~253.5 MiB). No AWS account, no Cognito user pool bill.

The Cognito-shaped job is apps, users, hosted sign-in, and OIDC clients you control. Authentik is the sibling post when you want reverse-proxy SSO for the whole homelab stack; Logto leans application CIAM.

ReadingValue
Imagesvhd/logto:1.40.1 (sha256:dabb2b3d…00ab1f3)
Databasepostgres:17-alpine named volume
Core (OIDC)127.0.0.1:3021 → container 3001
Admin127.0.0.1:3022 → container 3002
Issuerhttp://127.0.0.1:3021/oidc
Well-knownHTTP 200
Idle app / db / stack222.7 / ~30.8 / ~253.5 MiB
Diagram of Logto core on 3001, admin on 3002, and Postgres with a named volume.
Two public-facing roles, one database. Set ENDPOINT before you create production clients.

Cognito vs Logto in one screen

Cognito ideaLogto OSS
User poolUsers / tenants in Postgres
App clientApplication in Admin Console
Hosted UIExperience on the core endpoint
Issuer URL{ENDPOINT}/oidc
AWS accountYour VPS + backups

Official OSS docs often list roomy minimums (on the order of 8 GiB RAM for hosting). This quiet lab stack used ~254 MiB. Size for admin UX, peak logins, and growth; treat the idle number as a floor, not a capacity plan.

Compose with a database that survives restart

The upstream demo docker-compose.yml warns that re-running it can create a fresh bundled database and drop data. For a VPS, use a named volume and seed explicitly:

services:
  db:
    image: postgres:17-alpine
    container_name: logto-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: logto
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: logto
    volumes:
      - logto_pg:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U logto -d logto"]
      interval: 5s
      timeout: 5s
      retries: 10

  app:
    image: svhd/logto:1.40.1
    container_name: logto-app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
    ports:
      - "127.0.0.1:3001:3001"
      - "127.0.0.1:3002:3002"
    environment:
      DB_URL: postgres://logto:${POSTGRES_PASSWORD}@db:5432/logto
      ENDPOINT: ${ENDPOINT}
      ADMIN_ENDPOINT: ${ADMIN_ENDPOINT}
      TRUST_PROXY_HEADER: ${TRUST_PROXY_HEADER:-0}

volumes:
  logto_pg:
# .env (mode 600)
POSTGRES_PASSWORD='choose-a-long-secret'
ENDPOINT=http://127.0.0.1:3001
ADMIN_ENDPOINT=http://127.0.0.1:3002
TRUST_PROXY_HEADER=0

This lab mapped 3021/3022 because 3001/3002 were already taken on the host. Same idea: loopback only.

docker compose up -d
docker logs -f logto-app

You want Create tables, Seed OIDC config, then Core app is running / Admin app is running.

compose.yaml excerpt with Postgres volume and Logto ports on loopback.
Persistent volume. Seed in the entrypoint, not a one-shot demo wipe.
docker logs showing Logto db seed success and core/admin listening.
Seed once into the named volume, then start.

Prove OIDC discovery

curl -s http://127.0.0.1:3001/oidc/.well-known/openid-configuration \
  | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri}'

Lab output (ports 3021):

{
  "issuer": "http://127.0.0.1:3021/oidc",
  "authorization_endpoint": "http://127.0.0.1:3021/oidc/auth",
  "token_endpoint": "http://127.0.0.1:3021/oidc/token",
  "jwks_uri": "http://127.0.0.1:3021/oidc/jwks"
}

Register an Application in the admin console (ADMIN_ENDPOINT), set redirect URIs to match your app, and point the app’s OIDC client at this issuer. Changing ENDPOINT later changes the issuer and breaks existing clients, so set the public HTTPS URL before you ship.

jq extract of issuer, authorization_endpoint, token_endpoint, and jwks_uri from Logto well-known.
Discovery document is the Cognito-shaped contract your SDKs expect.

Idle RAM on this lab

ContainerMedian MiB (8 samples)
logto-app222.7
logto-db~30.8
Stack~253.5

Authentik’s sibling post measured about 966 MiB for server+worker+Postgres after setup. Different product shapes: Authentik for proxy SSO across many apps; Logto for user-pool style OIDC. Pick from the problem, then check RAM.

docker stats showing logto-app about 223 MiB and logto-db about 31 MiB.
Quiet after seed. Production traffic and social connectors will sit above this.

Caddy with two hostnames

auth.example.com {
    reverse_proxy 127.0.0.1:3001
}
admin.example.com {
    reverse_proxy 127.0.0.1:3002
}
ENDPOINT=https://auth.example.com
ADMIN_ENDPOINT=https://admin.example.com
TRUST_PROXY_HEADER=1

Recreate the app container after changing endpoints. Leave core/admin ports closed on the panel firewall. TLS pattern matches the Caddy HTTPS post. Restrict admin by VPN or IP allowlist when you can.

Caddyfile sketch reverse_proxy to Logto core and admin with TRUST_PROXY_HEADER note.
Public HTTPS hostnames; containers stay on loopback.
Skip thisWhat this lab did
Demo compose that recreates the DBNamed volume + db seed --swe
Publishing 3001/3002 on all interfaces127.0.0.1 only
Leaving ENDPOINT as localhost in productionDocumented HTTPS values before clients
Calling idle 254 MiB “enough for forever”Noted OSS 8 GiB-class guidance for hosting
Skipping well-knownCurled issuer + jwks

Docs vs the demo Compose file

Official Logto OSS docs still warn that the bundled docker-compose.yml is a demonstration: it ships Postgres next to the app, and re-running the compose command can seed a fresh database and wipe what you had. Production is a persistent Postgres 14+ (or 17 alpine in current samples), DB_URL pointed at that instance, and ENDPOINT / ADMIN_ENDPOINT set to the final HTTPS hostnames before you register clients. Changing the public endpoint later invalidates issuers and redirect URIs that apps already stored.

The same docs recommend roughly 2 vCPU and 8 GiB as a comfortable hosting floor. Quiet lab idle on a smaller guest can look much lighter; the 8 GiB figure is headroom for admin UI, connectors, and concurrent sign-ins, not a claim that the process always holds that much. Behind Caddy or Nginx set TRUST_PROXY_HEADER=1 so the OIDC issuer stays on https://… instead of falling back to a loopback HTTP URL. Vultr-style Cognito-alternative guides map User Pools to Logto users, Hosted UI to Sign-in Experience, and Lambda triggers to asynchronous webhooks. Cognito Identity Pools (AWS IAM role federation) have no direct Logto twin; keep that work in AWS or replace it with app-side credentials.

FAQ

Logto or Authentik? Authentik if you need outposts and forward-auth for arbitrary apps. Logto if you want Cognito-like user pools and OIDC apps with a dedicated admin console.

Redis? Optional. This lab logged No Redis client initialized, skipping and still served discovery.

Backups? pg_dump the logto database; treat it like any CIAM store. Link Restic for off-box copies.

Social login? Configure connectors in Admin after the stack is up; outbound HTTPS from the VPS must reach the IdP.

What to run on the VPS

A Compute Instance with 2 GB can hold this quiet stack with headroom; follow Logto’s larger minimum if you expect heavy admin use or many concurrent logins. Pin 1.40.1, keep Postgres on a volume, prove .well-known, set HTTPS ENDPOINT values, then create your first Application.