WiseHosting
Architecture

Database schema

Tables, columns, relationships, and the in-memory caches in front of them.

WiseHosting uses PostgreSQL. The schema is defined twice in lock-step: as GORM models in internal/database/models.go (used by the Go application at runtime) and as versioned golang-migrate SQL in internal/database/migrations/*.up.sql. RunMigrations() (called from Connect) embeds the SQL via embed.FS, hands it to golang-migrate's postgres driver, and applies any pending versions. A schema_migrations row tracks current state. There is no more AutoMigrate path — schema changes ship as new 000N_*.up.sql / 000N_*.down.sql pairs.

Two sources of truth, kept in sync

Why both Go structs and SQL files? The Go structs let GORM build queries (db.First(&app, id)) without us writing SQL. The migration files let us evolve the schema without GORM's AutoMigrate rewriting columns at startup — AutoMigrate is great for a prototype but ruins production change control.

Rule of thumb: add a migration first, then update the model, then write the code that uses it. Forgetting the migration step is the #1 cause of "works on my laptop, breaks in production".

Tables

users

ColumnTypeNotes
idintPK
emailstringunique, ≤255
google_idstringunique, ≤64 — only sign-in identity
namestring≤255
avatar_urltext
planstringdefault free
theme_prefstringsystem / light / dark
totp_secrettextAES-GCM-encrypted; empty until enrol
totp_enabledbooldefault false; flipped on first verify
totp_verified_at*timelast successful TOTP verification
created_attimedefault CURRENT_TIMESTAMP
updated_attimemaintained by BeforeCreate/Update
is_activebooldefault true

Apps []App cascade-deletes when a user is hard-deleted.

workers

ColumnTypeNotes
idintPK
namestringunique, required
ip_addressstring≤45
api_key_hashstringunique, JSON-omitted, sha256 hex (64) of raw key — raw never stored
zonestringDNS suffix this worker owns
region_namestringdisplay name
statusstringonline / offline, indexed
capacity_cpuintdefault 4
capacity_memoryint64default 4 GiB
used_cpuintcounter (incremented on assign)
used_memoryint64counter
last_heartbeattimeindexed

apps

ColumnTypeNotes
idintPK
uuidstringunique, public identifier
user_idintFK, indexed, composite uniq with name
namestringunique per user
repo_urltextrequired
subdomainstringunique
webhook_secretstringJSON-omitted
webhook_id*int64provider-side hook id
auto_deploybooldefault true
frameworkstringdefault dockerfile
root_dirstring
install_commandtext
build_commandtext
start_commandtext
statusstringdefault pending, indexed
worker_id*intFK, indexed
container_idstring
portinthost port allocated
last_violationtext
violation_at*time

deployments

ColumnTypeNotes
idintPK
app_idintFK
worker_id*intFK
statusstring
commit_shastring
commit_messagetext
commit_authorstring
branchstring
build_logstext
error_messagetext
started_at*time
completed_at*time
created_attimeindexed desc for recency queries

jobs

ColumnTypeNotes
idintPK
typestringdeploy / restart / start / stop / delete
app_id*int
deployment_id*int
worker_id*int
statusstringpending / assigned / processing / completed / failed
priorityintdefault 5; composite index with status desc
payloadtextAES-GCM-encrypted JSON
error_messagetext
last_messagestring
progressint0–100
assigned_at*time
started_at*time
completed_at*time

app_env_vars

ColumnTypeNotes
idintPK
app_idintcomposite uniq with key
keystringmatches ^[A-Z][A-Z0-9_]*$
valuetextAES-GCM-encrypted

git_provider_tokens

ColumnTypeNotes
idintPK
user_idintcomposite uniq with provider
providerstringgithub / gitlab / bitbucket / codeberg
usernamestring
tokentextAES-GCM-encrypted

webhooks

ColumnTypeNotes
idintPK
user_idintFK
app_id*intoptional scoping
namestring
urltextmust be HTTPS
secretstringwhsec_... 32 hex bytes
eventstextJSON array of event names
activebooldefault true
last_delivery_at*time
last_status_codeint
last_successbool
fail_count_7dint

webhook_deliveries

ColumnTypeNotes
idintPK
webhook_idintFK
eventstring
payloadtextrequest body sent
status_codeint
successboolindexed
errortext
attemptint
duration_msint
attempted_attimeindexed

sessions

ColumnTypeNotes
idintPK
user_idintFK, indexed
token_hashstringsha256(token) hex, uniqueIndex — never raw
ipstring≤64
city / country / country_codestring
user_agenttextcapped at 512 bytes
two_factor_verifiedbooltrue if the session was completed via 2FA
created_attime
last_seen_attimebumped at most once / minute
revoked_at*timenullable

audit_events

Renamed from login_events in migration 0005. Written on every sensitive user action; failures here are logged but never break the action.

Recorded kinds: 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.

ColumnTypeNotes
idintPK
user_idintindexed
kindstring≤32
ip / city / country / country_codestring
user_agenttext
metadatatextJSON blob — varies by kind
created_attimeindexed

app_alert_rules

Per-app rules driving the threshold poller in internal/alerts. The (app_id, kind) pair is unique.

ColumnTypeNotes
idintPK
app_idintuniqueIndex with kind
user_idint
kindstringcpu / memory / network / disk / offline / crashloop / deployment_failed
enabledbool
thresholdfloat64percent / MB·s⁻¹ / 0 for event-driven kinds
sustain_minutesinthysteresis window before fire and resolve
severitystringwarning / active

alerts

ColumnTypeNotes
idintPK
user_idintindexed
app_id*intnullable for platform-wide alerts
kindstringdeployment / violation / threshold / app
severitystringactive / warning / info
statusstringactive / acknowledged / resolved
sourcestringrule kind (cpu, offline, …)
titlestring
messagetext
created_attime
resolved_at*time

backup_codes

One-time TOTP recovery codes. Generated 10-at-a-time on first POST /api/me/2fa/verify; hashes stored argon2 (code_hash). used_at is stamped on first use and the row stays for audit. Cleared en-bloc when 2FA is disabled.

ColumnTypeNotes
idintPK
user_idintFK, indexed
code_hashstringargon2 hash of the plaintext code
used_at*timenullable; set on consumption
created_attime

domains

Custom hostnames that route to user apps via Traefik's HTTP provider (no container restart). The user adds a CNAME from <hostname> to the system subdomain, then proves control by adding a TXT record at _wisehosting.<hostname> matching verification_token. hostname is globally unique — no two users can claim the same name.

ColumnTypeNotes
idintPK
user_idintFK, ON DELETE CASCADE
app_idintFK, ON DELETE CASCADE
hostnamestring≤253, globally unique
verification_tokenstring≤64, opaque (wh-<hex>)
verified_at*timenullable until TXT match
last_check_at*timelast verification attempt
last_check_errortextlast failure reason
created_at / updated_attime

Verified hosts are emitted in the worker's /v1/traefik/config poll output as Host(\a`) || Host(`b`)` rules.

pending_totps

Short-lived state for the 2FA challenge step between OAuth success and final session issue. Replaces an earlier in-process sync.Map so the challenge survives a control-plane restart.

ColumnTypeNotes
tokenstringPK; sha256-style opaque cookie body
user_idintFK
providerstringalways google today; reserved for future SSO
expires_attime5-min TTL; PrunePendingTOTPs() runs every 6h

schema_migrations

Tracks golang-migrate state. Owned by the migration runner — application code does not read or write it directly.

ColumnTypeNotes
versionint64last applied migration version
dirtybooltrue if a migration failed mid-apply

A dirty row blocks subsequent startups until an operator manually reconciles, which is intentional — silent partial schema drift is the failure mode this replaces.

usage_samples

5-min rollup written by internal/usage.Recorder. Composite PK (app_id, bucket_start). 90-day retention enforced by a daily pruner.

ColumnTypeNotes
app_idintPK
bucket_starttimePK, UTC-truncated to 5-min
bucket_size_secintalways 300
user_idintindexed
cpu_pct_avgfloat64
cpu_pct_maxfloat64
mem_bytes_avgint64
mem_bytes_maxint64
net_mbps_avgfloat64
net_mbps_maxfloat64
net_bytes_deltaint64cumulative bytes transferred

In-memory caches

GormDB keeps a sync.Map cache with entry-specific TTLs:

Key shapeTTLUsed for
worker:apikey:<hash>30sWorker auth path (key already hashed)
app:id:<id>defaultSingle-app reads
app:user:<userID>:<name>defaultApp lookup by name
apps:user:<userID>2 minDashboard apps list

Cache invalidation is explicit — InvalidateUserCache and InvalidateAppCache are called on every mutation.

Encryption at rest

The master secret is api_server.secret. Per-purpose 256-bit keys are derived from it via HKDF-SHA256 with a purpose-specific info label, so a leak of one derived key never reveals another:

Purpose labelUsed for
wisehosting-aes-v1AES-256-GCM column encryption
wisehosting-oauth-state-v1OAuth state token signing
wisehosting-oauth-bind-v1OAuth bind cookie HMAC
wisehosting-completion-v1sign-in completion ticket
wisehosting-worker-jwt-v1Worker JWT HS256 signing

EnableSecretEncryption constructs an AES-256-GCM cipher from the AES purpose key. Every encrypted column stores ciphertext as v1:<base64url-no-pad>. Plaintext values written before encryption was enabled are passed through transparently, allowing zero-downtime rollout and rotation.

Worker API keys are not encrypted with the AES key — only sha256(rawKey) is stored as workers.api_key_hash. The raw key never lives in the DB.

On this page