Skip to content

Database schema

Shared GORM models and database utilities. Imported by apps/api and apps/proxy via the Go workspace replace directive.


FilePurpose
db.goOpen(), Migrate(), FromEnv(), RegisterMigration()
models.goAll 44 CE table definitions
types.goCustom JSONB types: EnvVarsMap, JSONObject, StringArray
crypto.goEncryptedString — AES-256-GCM GORM type

TablePurpose
usersIdentity
trusted_devicesRemembered devices — skips 2FA prompt on re-login
recovery_codesOne-time 2FA recovery codes (hashed)
dismissed_noticesConsole advisories a user has dismissed — per-user, keyed by a stable slug
agent_tokensmagt- tokens for agent principals (SHA-256 hashed, shown once)
cli_loginsA CLI waiting for someone to approve it in a browser (device flow): the user code both sides show, the device code’s hash, its state
cli_tokensmcli- tokens a CLI holds after an approved login, acting as the person who approved it (SHA-256 hashed, shown once)
console_sessionsOne row per sign-in to the console: browser, address, last seen, expiry. The console’s token names its row (sid) and is good only while the row is live, so a sign-in can be ended; a password change or two-factor turned off ends all but the current one
oauth_clientsMCP clients that registered themselves (OAuth dynamic registration): name, redirect URIs, and a client secret’s hash for one that asked for a secret
oauth_grantsApproved connections: who approved, the principal it acts as (them, or an agent), the organisation, the client’s name as approved, last use, revoked_at. One live per principal, client and organisation (idx_oauth_grant_live)
oauth_codesAuthorization codes: single use, five minutes, with the redirect URI and PKCE challenge they were issued for
oauth_tokensmoat- access and mort- refresh tokens under a grant, SHA-256 hashed; a refresh token is marked when traded
installed_licensesEnterprise licence tokens activated on this install
organizationsTenancy root. network_isolation closes each of its projects to the rest of the cluster
organization_membersUser ↔ Org join (roles: owner / admin / member)
mesh_reachWhether a member’s machines may reach a granted service, stack or project on the mesh, once someone chose; without a row, databases are off and everything else on
mesh_rulesNetwork rules: a machine, and ports on it, that a person’s machines, one machine or every machine of the organisation may reach; for what is not a Meshploy resource. Added to the generated mesh policy, no console permission
mesh_policy_stateOne row: whether the mesh obeys its generated access policy, who switched it, and when Headscale was last given a policy (or why that failed)
resource_permissionsPer-resource ACL grants (service, stack, job, project, route). source is empty for a grant given directly, else <kind>:<id> for one an edition derives and writes out per person; every check reads both, and the console gives and removes only direct ones. Unique per source (idx_resource_permission_grant_source)
org_invitationsEmail invitations to join an org
TablePurpose
projectsK8s namespace: slug becomes the namespace name. A row with parent_project_id is an environment level of that project (env_name, env_level: 0 is production, each level below counts up), with a namespace of its own, <project slug>-<level>. network_open exempts it from its organization’s network isolation; sandboxed runs its pods on gVisor
promotion_groupsServices that move up a project’s levels together, along their own path of level IDs, from the one that builds to production. single marks one made by copying one service down
promotion_group_membersA service lineage in a group; at most one group per lineage per project
nodesMesh worker nodes + K3s + Headscale metadata. os is linux, darwin or windows; anything but Linux is always mesh only
node_registration_tokensmreg-<hex> tokens for legacy worker self-registration
node_provisioning_tokensmprov-<hex> single-use provisioning tokens (hashed, with expiry)
domainsBase domains an org routes on. Each carries its own dns_mode (delegation or ondemand); is_primary marks the one whose platform subdomains serve and that new routes default to; retiring_at stops new routes attaching while everything already on it keeps serving; dns_provider_id, on an on-demand domain only, names the DNS provider that writes its records; project_id reserves it for one project and its levels, whose routes alone may use it (a project with one cannot be deleted until it goes)
TablePurpose
stacksDocker Compose stacks — parsed spec + services, and the images their built services keep for rollback (rollback_enabled, image_retention)
stack_runsOne Sync or Apply of a stack and its rollout: the services in depends_on layers, each with its deployment and state, and what the apply said. The request answers with the first layer; the rest are followed here
servicesPolymorphic workload: application or database. slug is the Kubernetes object name, fixed at creation and suffixed when the plain name is taken in the project; empty on pre-slug rows, which fall back to the display name. lineage_id ties the copies of one service across environment levels (a service’s lineage is COALESCE(lineage_id, id))
service_portsExposed ports per service
build_configsGit source or an uploaded folder (upload_*: its digest, name and size, kept as a blob in the service’s registry; built while git_repo is empty), builder type, registry target, and how many images it keeps for rollback: rollback_enabled on keeps the last image_retention (3 for a service made on its own), off keeps every one (1:1 with service)
database_configsEngine, version, storage size (1:1 with service)
volumesPersistent volumes
volume_mountsVolume ↔ Service mount (path + read-only flag)
volume_backup_configsBackup schedule for individual volumes
TablePurpose
variable_groupsNamed collections of key/value variables (project-scoped)
variable_group_itemsIndividual variable items within a group
service_variable_groupsService ↔ VariableGroup join
job_variable_groupsJob ↔ VariableGroup join
config_filesFiles projected into a workload at an absolute path; body is EncryptedString and never read back out (project-scoped)
service_config_filesService ↔ ConfigFile join
TablePurpose
routesHostname → service routing rule. In an environment level the hostname carries the level’s name (app-staging) while subdomain stays the service’s own; awaiting_deploy marks a route copied into a level with its service, published by that service’s first deploy there
ignored_endpointsDiscovered endpoints recorded as known and correct (org-scoped: node + address + port). Written by the API, not yet by the console
route_targetsTarget per route: a service, a node port, an address (with target_tls when it speaks HTTPS), or a redirect
tcp_routesA port the gateway publishes and forwards over the mesh
edge_versionsOne counter per thing the edge proxy keeps in memory (routes here; an edition adds its own), moved by every GORM write to the tables watched for it, in the write’s transaction. The proxy reads it each second and reloads only when it moved
edge_fallbacksDuring a migration whose edge was taken first: the old platform’s edge on its side port, and the hostnames the proxy hands it while it has no route of its own. One row for the gateway, removed at Finish
TablePurpose
deploymentsDeployment history + K8s artefacts + build log, and where the image came from: source (build, promotion, bring_down, rollback, image), branch, commit and its subject, and for an image moved between levels the level and deployment it came from; image_removed_at once its image was removed from the registry, when it can no longer be rolled back to
TablePurpose
jobsJob definition (image, command, schedule, concurrency)
job_runsIndividual run records (status, logs, started/finished at)
TablePurpose
storage_integrationsS3-compatible storage credentials (org-scoped)
registry_integrationsContainer registry credentials (org-scoped)
dns_providersConnected DNS hosts (Cloudflare): the API token encrypted, and the zones it may write records in as of its last check (org-scoped). A domain still using one keeps it from being removed
git_integrationsGit provider connections (GitHub App; GitLab, Gitea/Forgejo and Bitbucket by token or OAuth)
TablePurpose
backup_configsScheduled DB backup config (service-scoped)
system_backup_configsOrg-wide system backup config
notification_channelsSlack / Discord / webhook / email event routing
notification_deliveriesOne row per attempt to send an event to a channel (success, error, test, retry); pruned after 30 days
org_email_configsSMTP credentials per org
TablePurpose
templates1-click deployment blueprints (official + user-created)

db.Migrate() runs GORM AutoMigrate for all models, then applyConstraints(), which creates the unique indexes GORM cannot express as struct tags and runs a few idempotent data migrations (column cleanups and backfills):

IndexConstraint
idx_one_owner_per_orgExactly one owner per organisation (partial: WHERE role = 'owner')
idx_users_email_uniqueEmail unique among humans only (partial: WHERE email <> ''); agents carry an empty email
idx_variable_group_serviceAt most one system-managed variable group per service (partial)
idx_variable_group_item_keyItem keys unique within a variable group
idx_service_variable_groupA service attaches a given group at most once
idx_jobs_project_nameJob names unique within a project
idx_route_target_pathOne path rule per route
idx_resource_permission_grantNo duplicate permission grants

Domain names are unique across all organisations through the uniqueIndex tag on domains.base_domain, so one org cannot claim another’s domain.

idx_one_primary_domain_per_org is a partial unique index on domains(organization_id) WHERE is_primary: every base domain routes, and primary only decides whose platform subdomains serve, so two of them would leave that undecided.

Migrations run automatically on API startup; no migration CLI is needed.


EncryptedString is a custom GORM type that transparently encrypts on write and decrypts on read using AES-256-GCM. Call db.SetEncryptionKey(key) before any DB operation — the key must be exactly 32 characters.

Fields using this type (registry credentials, storage keys, git tokens) are stored as base64-encoded ciphertext and are never readable as plaintext in the database.


db.RegisterMigration(fn) registers additional schema migrations that run after AutoMigrate and applyConstraints. Call it from any package’s init() to extend the schema without modifying packages/db directly.


import dbpkg "github.com/meshploy/packages/db"
// Open from DATABASE_URL env var
db, err := dbpkg.FromEnv()
// Or explicit DSN
db, err := dbpkg.Open(dsn)
// Run migrations
dbpkg.Migrate(db)
// Set encryption key before any encrypted field access
dbpkg.SetEncryptionKey(os.Getenv("ENCRYPTION_KEY"))