Data layer
GORM wrapper, model definitions, AES-GCM encryption, naming, config, plans.
Files: internal/database/*, internal/models/models.go, internal/config/config.go, internal/plans/plans.go.
internal/database/gorm_db.go
Thin wrapper around *gorm.DB with an in-memory TTL cache (sync.Map), optional AES-GCM encryption for sensitive fields, and all application-level data-access methods. Establishes the Postgres connection (max 25 open / 5 idle, 5-min lifetime), runs RunMigrations on startup (see migrate.go), exposes ErrConflict and ErrAccountExists.
Connect(dsn) (*GormDB, error)— Opens, pings, configures pool, runsRunMigrations(db). Side effect: DDL applied viagolang-migratefrom the embeddedmigrations/*.sqlset. Refuses to start on adirtyschema_migrationsrow.(db) EnableSecretEncryption(serverSecret) error— Derives AES-GCM key from secret.(db) DecryptJobPayload(stored) string— Public wrapper; returns plaintext or original.ValidEnvKey(k) bool—^[A-Z][A-Z0-9_]{0,63}$.(db) InvalidateUserCache(userID)/InvalidateAppCache(appID)— Cache eviction.(db) ListAppEnvVars(appID)— Decrypts each value.(db) SetAppEnvVar(appID, key, value)— Validates key, encrypts, upserts.(db) DeleteAppEnvVar(appID, key).(db) AppEnvMap(appID)— Decryptedmap[string]string.(db) Close() error.(db) FindUserByID(id).(db) UpsertGoogleUser(googleID, email, name, avatar)— ON CONFLICT ongoogle_id.(db) UpsertGitToken(userID, provider, username, plaintext)— Encrypts + upserts.(db) DeleteGitToken(userID, provider).(db) GetGitToken(userID, provider) (token, username, ok)— Decrypts.(db) ListGitTokens(userID) []LinkedProvider.(db) MarkWorkerOnline(workerID)/ListWorkers()/FindWorkerByID(id).(db) FindOnlineWorkerByAPIKey(rawKey)— HashesrawKeyfirst, queries onapi_key_hash. 30 s cache (keyed by hash). Despite the name, matches workers regardless ofstatusso anofflineworker can still reconnect (see wsproto).(db) ListAppsByWorker(workerID)— Apps currently scheduled on this worker. Used by/v1/traefik/configto emit per-app routers for verified custom domains.HashAPIKey(rawKey) string/HashAPIKeyBytes(rawKey) []byte— Public helpers exposing the same hash used at rest. Bytes form is the WSS HMAC signing key (recovered server-side fromapi_key_hash, derived independently on the worker side).(db) UpsertWorker(name, ip, rawAPIKey, zone, regionName, capCPU, capMem)— Storessha256(rawAPIKey)asapi_key_hash; raw key never persisted.(db) UpdateWorkerHeartbeat(workerID, status, res).(db) FindAppByUserIDAndName(userID, appName)/FindAppByID(appID)— Cache-first.(db) ListUserApps(userID)— 2-min cache.(db) MarkAppDeleted(appID)/HardDeleteApp(appID)— The hard variant cancels pending/assigned jobs in a tx.(db) MarkAppStopped(appID)/MarkAppViolated(appID, reason).(db) FindDeployment(id)/ListDeployments(appID, limit).(db) ListRecentUserDeployments(userID, limit)— Raw SQL join acrossappsfor non-deleted apps.(db) LatestBuildLogs(appID)— From the most recent deployment.(db) SetDeploymentBuildLogs(deploymentID, workerID, logs)— Verifies worker owns deployment, strips null bytes.(db) FindJobForWorker(jobID, workerID)/NextAssignedJob(workerID)— Decrypts payload.(db) UpdateJobStatus(jobID, workerID, status, message, progress)— Setsstarted_aton transition toprocessing.(db) FindActiveJobForApp(appID)— Most recent pending/assigned/processing.(db) CompleteJob(job, status, result)— Tx: marks job + updates app status + deployment fields.(db) CreateDeploymentWithSpec(...)— Tx: unique app name/subdomain, env vars, deployment, encrypted job payload.(db) SetAppWebhookID(appID, hookID)/UpdateAppSpec(appID, updates)/SetAppAutoDeploy(appID, enabled).(db) FindAppByUUID(uuid).(db) CreateRedeployment(appID, workerID int, payload, commitSHA, branch string)— Tx: deployment + restart job pinned, appdeploying. Commit metadata is persisted on the deployment row up-front so failure paths still carry the right git identity.(db) CreatePinnedJob(jobType, appID, workerID, payload, appStatus).(db) CreateDomain(d)/ListDomainsForUser(userID)/ListDomainsForApp(appID)/FindDomain(id, userID).(db) ListVerifiedDomainsForApp(appID) []string— Hostname-only slice; used bytraefikConfigto renderHost()rules.(db) MarkDomainVerified(id)/MarkDomainCheckFailed(id, reason)/DeleteDomain(id, userID).(db) ListWebhooks(userID)/ListWebhooksForEvent(event)/FindWebhook(id, userID)/FindWebhookByID(id).(db) CreateWebhook(w)/UpdateWebhook(id, userID, updates)/DeleteWebhook(id, userID)— The latter cascadeswebhook_deliveries.(db) RecordWebhookDelivery(d)— Inserts + updates parent counters.(db) ListWebhookDeliveries(webhookID, userID, limit).(db) WebhookStats(userID) (total, active, failed7d, lastAt, err).
Sessions + audit
(db) CreateSession(userID, tokenHash, ip, geo, ua, twoFactor bool) (sessionID, err)— Inserts asessionsrow, recording whether the issue passed through 2FA.(db) FindSessionByTokenHash(hash) (Session, err)— Looks up bysha256(token); honoursrevoked_at.(db) BumpSessionLastSeen(sessionID, ip, ua)— Debounced (caller throttles to once/min). Drives the rolling 30-day TTL — idle sessions expire fromlast_seen_at, notcreated_at.(db) ListSessionsForUser(userID) []Session— Newest first.(db) RevokeSession(id, userID)/(db) RevokeOtherSessions(userID, exceptID).(db) PruneRevokedSessions(maxAge) error— Hard-deletes revoked or 30+ day-old rows. Called every 6 hours frommain.go.(db) RecordAuditEvent(e)— Append-only insert intoaudit_events. Replaces the oldRecordLoginEventafter the table was renamed in migration0005.(db) ListAuditEvents(userID, limit) []AuditEvent.(db) HasAuditEventForIP(userID, ip) bool— Drives theaccount.login_new_devicewebhook (formerlyHasLoginEventForIP).
Audit kinds emitted by the web layer: login, logout, session_revoked, app_deploy, app_restart, app_start, app_stop, app_delete, env_set, env_delete, git_token_revoke, domain_create, domain_verify, domain_delete.
2FA / TOTP
(db) SetTOTPSecret(userID, encSecret)/(db) GetTOTPSecret(userID)— Stores and reads the AES-GCM-encrypted shared secret onusers.(db) EnableTOTP(userID)/(db) DisableTOTP(userID)— Fliptotp_enabled, stamptotp_verified_at, clear backup codes on disable.(db) ReplaceBackupCodes(userID, hashes []string)— Bulk-replace argon2 hashes inbackup_codes; called on first verify and re-generate.(db) ConsumeBackupCode(userID, plaintext) (ok bool, err)— Argon2-compares against unused rows; setsused_aton hit.(db) CreatePendingTOTP(userID, provider, ttl) (token string, err)— Inserts apending_totpsrow keyed by an opaque token (the value of thewh_2fa_pendingcookie).(db) ConsumePendingTOTP(token) (userID, provider, err)— Atomically deletes and returns the row; rejects expired entries.(db) PrunePendingTOTPs() error— Bulk-delete expired challenges. Called every 6 hours frommain.go.
Alerts
(db) EnsureAppAlertRules(appID, userID)— UPSERTs the default rule set if missing.(db) ListAppAlertRules(appID) []AppAlertRule.(db) UpsertAppAlertRule(rule).(db) ListEnabledAlertRules() []AppAlertRule— Used by the threshold poller.(db) ListAppsForRules() []App— Running apps with their owner.(db) CreateAlert(a)/(db) FindActiveAlertBySource(userID, srcType, srcID) (Alert, ok).(db) ResolveAlertsBySource(userID, srcType, srcID).(db) ListAlerts(userID, filter) ([]Alert, AlertStats)— Filter by status/kind/app, returns the headline counters used by the dashboard.(db) UpdateAlertStatus(id, userID, status).
Usage time-series
(db) UpsertUsageSample(s)— Inserts/updates by composite PK(app_id, bucket_start).(db) ListUsageSamples(scope, from, to) []UsageSample— Bounded by 90-day retention.(db) PruneUsageSamples(olderThan time.Time) int64— Daily pruner.
internal/database/migrate.go
Thin wrapper around golang-migrate that lets the binary ship its own SQL.
RunMigrations(db) error— Builds aniofssource frommigrations/*.sql(embedded viaembed.FS), wraps the live*sql.DBingolang-migrate/database/postgres, and callsUp(). No-op when current. Returns a structured error on adirtyschema_migrationsrow so the caller can fail fast.
Schema changes ship as new 000N_<slug>.up.sql / 000N_<slug>.down.sql pairs in internal/database/migrations/. There is no GORM AutoMigrate path anymore — model struct changes that aren't backed by a migration will pass go build but fail at runtime when GORM scans columns.
internal/database/models.go
GORM model definitions with explicit TableName() methods and BeforeCreate/BeforeUpdate hooks on User. See Architecture → Database schema for full column tables.
Models: User, Worker, App, Deployment, Job, AppEnvVar, GitProviderToken, Webhook, WebhookDelivery, Session, AuditEvent, AppAlertRule, Alert, UsageSample, BackupCode, PendingTOTP, Domain.
Notable column-level details: Worker.APIKeyHash (column api_key_hash, sha256 hex) replaces the former APIKey. Job no longer has MaxRetries / RetryCount (dropped in migration 0004).
- All
(<Model>) TableName() string— Explicit table name. (u *User) BeforeCreate(tx) error— StampsCreatedAt/UpdatedAtif zero.(u *User) BeforeUpdate(tx) error— SetsUpdatedAt = now.
internal/database/naming.go
ValidateAppNameInput(s) error— Length 7-32, charset[a-z0-9-], not in reserved blocklist.NewAppUUID() string— UUID v4 fromcrypto/rand.GenerateAppName() string—adjective-noun-NNNNfrom two 64-word lists viamath/rand/v2.
internal/database/secret.go
AES-256-GCM authenticated encryption for sensitive strings. Keys are derived from api_server.secret via HKDF-SHA256 with a per-purpose info label, so different uses (column AES, OAuth state, JWT signing) get independent keys.
deriveKey(masterSecret, purpose) []byte— HKDF-SHA256, 32-byte output.newSecretCipher(serverSecret) (*secretCipher, error)— Uses purposewisehosting-aes-v1.(s) Encrypt(plain) (stored, err)— Returnsv1:<base64url-no-pad>; empty in → empty out.(s) Decrypt(stored) (plain, err)— Pass-through if thev1:prefix is absent (supports zero-downtime rollout and key rotation).
Type and constructor are package-private; surfaced via GormDB helpers above.
internal/models/models.go
Pure Go structs used for inter-service messaging. No GORM, no DB.
DeployPayload— Build/run parameters.ViolationReport,AppStatusReport.ResourceLimits— CPU/memory/PIDs/disk/bandwidth.JobResult— Container ID, port, logs, commit metadata, error.HeartbeatRequest,WorkerResources,JobStatusUpdate.
internal/config/config.go
Loads YAML from $CONFIG_PATH (default config.yaml); typed accessor methods for every field. See Configuration for the full field catalogue.
Load() (*Config, error)— Reads file, then overlays any systemd-supplied credentials (see below), thenValidate.loadCredential(name) (string, bool)— Reads$CREDENTIALS_DIRECTORY/<name>populated by systemdLoadCredential=. The post-parse overlay wins over file values for:api_server_secret,database_password,google_oauth_client_secret,github_oauth_client_secret,gitlab_oauth_client_secret,bitbucket_oauth_client_secret,codeberg_oauth_client_secret. Letsconfig.yamlship without secrets.(c) Validate() error— Required:database.password,api_server.secret(≥16 chars),container.port_range_*,platform.domain.(c) DatabaseDSN()/APIServerAddr()/APIServerSecret()/APIServerTLS()/APIServerBaseURL().(c) JobPollInterval()(5 s) /JobBuildTimeout()(10 m) /JobContainerStartTimeout()(30 s) /APIRateWindow()(1 m) — All with parse fallbacks.- One accessor per field for WebApp URL, all 5 OAuth providers (Google + 4 git providers), platform, worker, container, security, logging.
internal/plans/plans.go
Static plan registry; runtime overrides via YAML.
| Plan | MaxApps | CPU | Memory | Disk | Bandwidth |
|---|---|---|---|---|---|
free | 1 | 1 | 512 MiB | 1 GiB | 10mbit |
pro | 5 | 2 | 2 GiB | 10 GiB | 100mbit |
business | 0 (unlimited) | 4 | 8 GiB | 50 GiB | unlimited |
For(id) Plan— Returns plan by ID; falls back tofree.All() []Plan—[free, pro, business].Apply(overrides)— Mutates the global registry.