{
  "id": "osc-stack-migrate-backend-to-postgresql",
  "job": "Move an app's backend off a hosted Postgres bundle (database, sign-in, file storage, generated API) onto open source services, keeping users' passwords, then switch traffic.",
  "aliases": [
    "migrate hosted postgres backend to open source",
    "move a backend-as-a-service project to self-hosted postgresql",
    "migrate users with bcrypt hashes to keycloak"
  ],
  "inputs": [
    {
      "protocol": "postgres",
      "source": "a pg_dump of the schemas the app owns (--schema=public plus any app-created schema, --no-owner --no-privileges); the platform's auth and storage schemas are left out"
    },
    {
      "protocol": "csv",
      "source": "a users export with id, email, bcrypt password hash, email confirmation time and creation time (on Supabase: select id, email, encrypted_password, email_confirmed_at, created_at from auth.users). Sensitive: delete after import."
    },
    {
      "protocol": "S3",
      "source": "the current file buckets, read with any S3 client"
    }
  ],
  "outputs": [
    {
      "protocol": "postgres",
      "consumer": "the app, through a connection string kept in a parameter store"
    },
    {
      "protocol": "OpenID Connect",
      "consumer": "the app's sign-in, through Keycloak's realm configuration"
    },
    {
      "protocol": "S3",
      "consumer": "the app's file reads and writes, through the MinIO endpoint"
    }
  ],
  "distinctFrom": [
    {
      "id": "osc-stack-provision-agent-backend",
      "descriptorUrl": "https://www.osaas.io/stacks/provision-agent-backend.json",
      "why": "That recipe creates a new PostgreSQL and MinIO from scratch. This one moves existing data and users into them."
    },
    {
      "id": "osc-stack-migrate-nextjs-from-frontend-cloud",
      "descriptorUrl": "https://www.osaas.io/stacks/migrate-nextjs-from-frontend-cloud.json",
      "why": "That recipe moves a Next.js app and its data off a frontend cloud. This one moves a backend bundle and its users; follow that one for the app itself if it is a Next.js app."
    }
  ],
  "recommendedPath": {
    "chain": [
      {
        "step": 1,
        "serviceId": "birme-osc-postgresql",
        "role": "relational database replacing the hosted Postgres",
        "requiredConfig": ["name", "PostgresPassword"],
        "requiresCredential": true
      },
      {
        "step": 2,
        "action": "restore data",
        "role": "pg_restore or psql the app-owned schemas into step 1",
        "note": "Drop or repoint foreign keys that reference auth.users (that table does not exist on the target) before restoring. Rewrite row-level security policies that grant to the platform's roles (anon, authenticated, service_role) or call auth.uid() before restoring them; those roles and functions do not exist on a plain PostgreSQL."
      },
      {
        "step": 3,
        "serviceId": "keycloak-keycloak",
        "role": "sign-in, replacing the bundled auth",
        "requiredConfig": ["name", "DatabaseUrl", "AdminUser", "AdminPassword"],
        "requiresCredential": true,
        "note": "DatabaseUrl points at a PostgreSQL instance. Import users through the admin REST API (POST /admin/realms/<realm>/users) with a password credential whose secretData is {\"value\": \"<bcrypt hash>\"} and credentialData is {\"hashIterations\": 10, \"algorithm\": \"bcrypt\"}. Turn off the Verify Profile required action first. Full script and checks: guideUrl. Keycloak assigns each imported user a NEW id; the old id is kept only as the supabase_id user attribute. After import, update every user-id column in the app's tables (and any policy or code comparing against it) to the new Keycloak id, mapped via supabase_id."
      },
      {
        "step": 4,
        "serviceId": "minio-minio",
        "role": "S3-compatible file storage replacing the bundled buckets",
        "requiredConfig": ["name"],
        "requiresCredential": false,
        "note": "Copy files with aws s3 sync (or any S3 client) using the instance endpoint."
      },
      {
        "step": 5,
        "serviceId": "postgrest-postgrest",
        "role": "optional: REST API generated from the PostgreSQL schema",
        "requiredConfig": ["name", "DbUri"],
        "requiresCredential": true,
        "note": "Only if the app calls the generated REST API."
      },
      {
        "step": 6,
        "mcpTool": "setup-parameter-store",
        "role": "environment variables: the new connection string, the Keycloak address and the MinIO endpoint",
        "requiredConfig": ["name", "enableSecrets"],
        "requiresCredential": false
      },
      {
        "step": 7,
        "action": "switch traffic",
        "role": "freeze writes or run a final sync, point the app at the new backend, keep the old project until verified, then remove it"
      },
      {
        "step": 8,
        "serviceId": "eyevinn-db-backuper",
        "role": "optional: PostgreSQL backups to S3-compatible storage, AES-256 encryption optional",
        "requiredConfig": ["name", "Operation", "DatabaseUrl"],
        "requiresCredential": true
      }
    ],
    "tokensPerDay": null,
    "tokensPerDayMeasured": false,
    "tokensPerDayNote": "Catalog estimates 2026-10-20 (compare-services): PostgreSQL ~5/day, Keycloak ~10, MinIO ~10, PostgREST ~10, Database Backuper ~10 per instance. Not measured for this recipe.",
    "measuredWallClockSeconds": null,
    "wallClockMeasured": false,
    "gapNote": "Only the user step has been run end to end: users exported from Supabase, imported into Keycloak on OSC with their bcrypt hashes, correct password returned 200 with a token, wrong password returned 400 invalid_grant (guideUrl). The database restore, file copy and traffic switch were not part of that run."
  },
  "guideUrl": "https://dev.to/oscdev/moving-supabase-users-to-open-source-cloud-without-resetting-their-passwords-2jga",
  "doesNotMove": [
    "the platform's auth and storage schemas: users go to Keycloak, files go to MinIO",
    "user ids: Keycloak assigns new ids on import (old id kept as the supabase_id attribute); remap user-id columns, and drop or repoint foreign keys to auth.users",
    "row-level security policies tied to the platform's roles or auth functions: rewrite them",
    "edge functions: no like-for-like runtime; run the code as a Node.js My App, which may need changes",
    "realtime subscriptions: Centrifugo (centrifugal-centrifugo) is a messaging server, not a drop-in; the app publishes events itself",
    "vendor client library calls for sign-in and files: swap for an OpenID Connect library and an S3 client"
  ],
  "notVerified": [
    "a timed migration from start to finish",
    "restoring a real project's app schemas into birme-osc-postgresql",
    "user id remapping after import (mapping app rows from supabase_id to the new Keycloak id)",
    "password hashes other than bcrypt ($2a$, $2b$, $2y$)",
    "realtime via Centrifugo",
    "edge functions as My Apps",
    "moving a large file store",
    "Hasura GraphQL Engine as a replacement for a generated GraphQL API"
  ],
  "servicesVerifiedLive": {
    "date": "2026-10-20",
    "method": "osc_call_tool wrap (name=compare-services) on birme-osc-postgresql, keycloak-keycloak, minio-minio, postgrest-postgrest, centrifugal-centrifugo, eyevinn-db-backuper; list-service-categories.",
    "catalogTotal": 199,
    "catalogTotalSource": "list-service-categories via osc_call_tool on 2026-10-20, per-category sum 10+27+13+31+66+25+19+8 = 199"
  },
  "gateCheck": {
    "gatedServiceId": "eyevinn-just-go-live",
    "usesGatedService": false,
    "note": "This stack does not name or link eyevinn-just-go-live."
  },
  "verifiedOn": "2026-10-20",
  "publishedAt": "https://www.osaas.io/migrate/from-hosted-postgres-backend",
  "descriptorUrl": "https://www.osaas.io/stacks/migrate-backend-to-postgresql.json"
}
