think
16px
820px

Enterprise → Cloud Enterprise

Upgrading a single-tenant Obscura deployment into a Cloud one holding that customer as its first
tenant — which is what unlocks the operator console, billing, quotas, plans and credits.

Why this is a rename, not a data migration

Both editions run the same application migration set. Enterprise applies it to public; Cloud
applies the identical set to t_<tenant> (migrations.Up vs migrations.UpInSchema). The 150-odd
tables, their columns and their data are the same either way — only the schema name differs.

PostgreSQL renames a schema by updating catalogue rows, so the database step costs the same on
20 GB as on 20 MB. What Cloud adds is a control schema and host-based routing; what it changes
is small, and each piece was built so a conversion survives it:

Enterprise Cloud on conversion
app tables public t_<id> renamed, one statement
blob keys <hash> <tenant>/<hash> copied — the only step proportional to data
audit chain key deployment-wide derived per tenant nothing to do: the epoch is stamped per row, so old rows keep verifying under the old key
bootstrap (RBAC seeds, e-Sign CA, correspondence defaults) at boot per tenant already done — the converted schema keeps its rows
operator console unavailable available this is the point of the upgrade

The console is unavailable on Enterprise by construction, not by configuration:

func (s *Server) controlPlaneEnabled() bool {
    return s.multiTenant && strings.TrimSpace(s.controlToken) != "" && s.tenantAdmin != nil
}

So "attach an operator to an existing Enterprise deployment" means "convert it to Cloud with one
tenant" — there is no other route, and there is deliberately no half-way state.

The tool

obscura-convert, shipped inside the server image — it needs the deployment's own database and
object store, so it runs where those are reachable:

docker compose run --rm --entrypoint /obscura-convert obscura -tenant-id acme

The default is a read-only plan; nothing changes without -apply. It reads DATABASE_URL and
the S3_* variables from the environment, so inside compose it is already pointed at the right
database and bucket.

obscura-convert -tenant-id acme                       # plan (read-only)
obscura-convert -tenant-id acme -apply                # move the schema (one transaction)
obscura-convert -tenant-id acme -blobs                # plan the object move
obscura-convert -tenant-id acme -blobs -apply         # copy objects to <tenant>/
obscura-convert -tenant-id acme -register             # write the control row, after a Cloud boot
obscura-convert -tenant-id acme -blobs -prune         # delete originals, after verification

It refuses, with the reason, when:

  • public holds no tables (not an Enterprise database — possibly already converted)
  • there are tables but no goose_db_version (not built by Obscura's migrations; the tenant's
    schema version would be unknowable)
  • the schema version is behind this build (migrate first — a window that contains both the
    conversion and a migration makes the first failure ambiguous)
  • t_<id> already exists
  • control.tenants already holds tenants (this is a Cloud database; renaming its public would
    take the shared extension schema away from every tenant at once)

Runbook

  1. Back up. Then rehearse the whole thing on the restored copy — the tool is idempotent and
    the rehearsal is the only way to learn your own object count.
  2. Migrate to the current build if the plan says the schema version is behind.
  3. Stop the deployment.
  4. obscura-convert -tenant-id <id> -name "<Customer>" -apply
  5. obscura-convert -tenant-id <id> -blobs -apply — the long step. Copies, never moves, so it is
    resumable and abandonable.
  6. Start with TENANCY_MODE=schema, TENANT_BASE_DOMAIN, CONTROL_PLANE_TOKEN. The
    control-plane migrations run on this boot.
  7. obscura-convert -tenant-id <id> -register
  8. Point DNS/vhost at <id>.<base domain> and verify sign-in.
  9. Days later, once you are satisfied: obscura-convert -tenant-id <id> -blobs -prune.

Do not skip step 5. Convert the database without the objects and every document is a row
pointing at nothing.

What is reversible

  • The database step is one transaction — it either converted or it did not.
  • Objects are copied, never moved. An interrupted pass resumes; an abandoned conversion
    leaves them untouched.
  • -prune is the only irreversible act, is opt-in, and runs only after every object has been
    verified present at its new key.

To roll back before pruning: stop, ALTER SCHEMA t_<id> RENAME TO public, drop control, and
start with TENANCY_MODE=single. The bare-hash objects are still there.

Rehearsed end to end

On a scratch database: a real Enterprise deployment (152 tables), converted, booted as Cloud with
zero errors, tenant registered and visible to the operator console at schema v186 — no
re-migration. An admin seeded through the operator API signed in over the tenant host, and the
document list returned both the row written before the conversion and one created after, with
its upload landing at acme/<hash>.