DSL design guidelines

Copy Markdown View Source

HostKit DSL is for humans first. It should read like a declaration of the host, not a transcription of systemd, SSH, Caddy, or file paths. The implementation must still compile to plain inspectable structs.

Core principles

  1. One human concept, one DSL concept. Do not split one idea across unrelated macros. A managed runtime env file is env; it should not require users to pair env_file with environment_file in normal code.
  2. Context is part of the DSL. The same word may be valid in different scopes when the human concept is the same. Example: env :runtime do ... end declares the env file in service; env :runtime attaches it in daemon.
  3. Names are logical references. Prefer symbolic names for objects declared in the same HostKit project: :data, :runtime, :http. Resolve them to paths, ports, upstreams, and systemd directives at compile time.
  4. Paths are derived unless path choice is the point. README and happy-path docs should not repeat /var/lib/app, /etc/app/env, or unit names. Use storage/env/service conventions and only expose explicit paths for overrides.
  5. Blocks group configuration. Statements declare facts. Use do/end when several settings configure one concept. Use a statement for a single fact or reference.
  6. Good defaults beat required boilerplate. Common daemons should derive the unit name and default to multi-user.target. Root SSH should not imply sudo.
  7. Escape hatches are allowed but not the happy path. Low-level systemd directives and explicit file paths are valid for advanced guides, not the first example.

Naming rules

Generated names belong in HostKit.Naming, not in individual recipes/providers. Centralize path segments, identity segments, unit names, service users, readiness names, ingress route names, and command/resource names there so user-facing DSL stays consistent.

Use nouns for declared things

Declared project objects should be nouns:

host :app, at: "app.example.com"
storage :data
env :runtime
service :api

Use verbs for actions inside a configured thing

Inside a block, verbs express how that object behaves:

ssh do
  user "root"
  identity_file Path.expand("~/.ssh/id_ed25519")
  accept_hosts true
end

caddy_site "api.example.com" do
  reverse_proxy :http
end

Avoid leaking backend names

Do not expose backend vocabulary when the human intent is clearer:

  • Prefer env :runtime over environment_file "/etc/app/runtime.env".
  • Prefer exec [...] over exec_start [...] in normal app examples.
  • Prefer isolate do ... end over raw systemd sandbox keyword lists.
  • Prefer reverse_proxy :http over reverse_proxy listener(:http).

Backend-specific names remain acceptable in low-level reference sections.

Blocks vs statements

Use a block when the concept has internal structure:

host :app, at: "app.example.com" do
  ssh do
    user "root"
    identity_file Path.expand("~/.ssh/id_ed25519")
  end
end

service :api do
  env :runtime do
    secret :database_url, env: "DATABASE_URL"
  end
end

Use a statement when declaring one fact:

storage :data, mode: 0o750
listen :http, port: 4000
memory_max "512M"
writable :data

Do not force keyword bags for nested configuration when a block is more readable:

# Prefer
ssh do
  user "deploy"
  sudo true
  retry attempts: 3
end

# Avoid in docs
ssh user: "deploy", sudo: true, retry: [attempts: 3]

Hosts vs instances

A host is a connection endpoint. A top-level host points at an existing target; HostKit does not create, start, stop, reset, or destroy it.

host :prod, at: "prod.example.com" do
  ssh do
    user "deploy"
    sudo true
  end
end

An instance is a lifecycle-managed compute boundary. It selects a backend, image/kind, port exposure, nested host endpoint(s), and normal HostKit contents that should exist inside it.

instance :demo do
  backend :incus
  image "images:ubuntu/24.04"
  kind :container
  lifecycle :ephemeral

  expose :ssh, host: 2222, guest: 22

  host :guest, at: "127.0.0.1" do
    ssh do
      user "root"
      port 2222
      accept_hosts true
    end
  end

  service :web do
    package :caddy
  end
end

Rule: instance manages the compute lifecycle; nested host describes how HostKit connects into that instance. Plans order the instance lifecycle resource before nested content resources, and nested content carries the selected nested host target metadata so read/apply operations execute inside the managed compute boundary. Use target_host :name when an instance has multiple nested host endpoints.

References between declarations

Use symbolic names for intra-project references.

Storage

storage :data, mode: 0o750

isolate do
  writable :data
end

:data resolves to the declared storage path. Users should not repeat that path in read_write_paths.

Env

env :runtime do
  secret :database_url, env: "DATABASE_URL"
end

daemon do
  env :runtime
end

:runtime resolves to the managed env file. Users should not repeat the env file path.

Listeners

daemon do
  listen :http, port: 4000
end

caddy_site "api.example.com" do
  reverse_proxy :http
end

:http resolves to the declared loopback listener upstream.

Defaults

Host / SSH

Root SSH should not set sudo:

host :app, at: "app.example.com" do
  ssh do
    user "root"
    identity_file Path.expand("~/.ssh/id_ed25519")
  end
end

A non-root deploy user opts into sudo explicitly:

ssh do
  user "deploy"
  sudo true
end

Daemon

Inside service :api, daemon do ... end means:

  • unit name derives from the service (api.service by default),
  • install target defaults to multi-user.target,
  • if the service declared account system: true, the daemon defaults to that account for User= and Group=,
  • low-level systemd install directives are omitted from happy-path code.

Use explicit systemd directives only for non-default boot behavior.

Mise

mise do ... end uses HostKit's system-wide defaults for the mise binary and data directory. Explicit path: and system_data_dir: are advanced overrides.

Isolation naming

The README should use:

isolate do
  memory_max "512M"
  writable :data
  network :loopback
end

The profile name is an internal preset selected by the DSL default. Advanced users may write isolate :untrusted do ... end when choosing a specific profile is the point.

Profile names must describe security intent, not implementation mechanics. Avoid vague names in first-touch docs. :strict_app currently means the default strict service sandbox: no new privileges, protected system/home/kernel surfaces, restricted address families, explicit writable paths, and resource controls.

DSL layering policy

HostKit keeps low-level directives when they expose a real Linux/systemd/provider primitive that advanced users may need. Those directives are escape hatches, not canonical application DSL.

Stays as escape hatch

These stay available and documented in the directive inventory/reference because they map directly to backend concepts:

  • systemd_service, systemd_timer
  • unit, service, timer, install
  • environment_file, exec_start, exec_stop, wanted_by, read_write_paths
  • explicit dotenv path do ... end
  • explicit listener(:name) when a string upstream is required
  • explicit named Caddy sites: caddy_site :name, "host" do ... end

Not canonical in user-facing docs

These should not appear in README/getting-started/tutorial happy paths unless the section is explicitly about the low-level primitive:

  • split host declarations; use host :name, at: ...
  • root SSH plus sudo true; root does not need sudo
  • service :bootstrap; use bootstrap do
  • paired env_file and environment_file; use contextual env
  • raw exec_start; use exec, and use argv(...) when the command has structured CLI options
  • raw wanted_by :multi_user; daemon do defaults it
  • raw read_write_paths; use writable :storage
  • raw sandbox keyword lists; use isolate do
  • reverse_proxy listener(:http); use reverse_proxy :http

README standard

The README example should showcase killer features without plumbing:

  • host declaration with SSH block,
  • bootstrap packages and mise runtimes,
  • service account,
  • named storage,
  • managed env secrets,
  • derived daemon,
  • Docker-less isolation,
  • loopback listener,
  • Caddy reverse proxy by listener name.

It should not show repeated paths, explicit multi-user.target, environment_file, read_write_paths, or listener(:http) unless explaining internals.

Directive inventory

This inventory lists the public macros exported by HostKit's core DSL, systemd DSL, Caddy provider DSL, and Elixir app recipe DSL. It is intentionally explicit so the reference does not drift from the actual directive surface.

Legend:

  • Canonical — preferred in README and normal project examples.
  • Reference — useful, but belongs in reference/provider/recipe docs rather than the first example.
  • Escape hatch — low-level backend vocabulary for advanced cases.

Core project and composition

DirectiveLevelPurpose
projectCanonicalTop-level declaration that returns a %HostKit.Project{}.
providersReferenceSet provider modules for the project. Prefer use HostKit.DSL, providers: [...] in examples.
providerReferenceConfigure one provider, such as Caddy paths.
rootsReferenceDeclare project path roots.
prefixesReferenceDeclare naming prefixes for users, units, etc.
tenantReferenceDeclare a tenant and corresponding workspace scope.
workspaceReferenceScope path and identity conventions for per-user/per-workspace services.
put_in_metaEscape hatchAttach arbitrary metadata to the current service.

Hosts, instances, and SSH

DirectiveLevelPurpose
hostCanonicalDeclare a named connection endpoint; top-level hosts are existing targets, nested hosts are endpoints into an instance.
instanceCanonicalDeclare a lifecycle-managed compute instance with backend, image, exposed ports, nested hosts, and nested HostKit contents.
backendCanonicalSelect the implementation backend for an instance, ingress, proxy, or other backend-driven declaration. Accepts backend-neutral option syntax such as backend :incus, sudo: true.
optionReferenceSet a backend option inside backend ... do; options stay attached to the selected backend instead of leaking into generic plan/apply flags.
imageCanonicalSet the instance image.
kindCanonicalSet the instance kind, such as :container or :vm.
lifecycleReferenceSet instance lifecycle policy, such as :persistent or :ephemeral. Ephemeral instances are deleted in down plans; persistent instances are skipped with a warning.
target_hostCanonicalSelect which nested host endpoint receives nested instance content resources when multiple nested hosts exist.
exposeCanonicalDeclare an instance port exposure from host to guest.
sshCanonicalConfigure host SSH transport as a block.
userCanonicalSSH user inside ssh.
identity_fileCanonicalSSH key path inside ssh.
passwordReferenceSSH password/secret inside ssh.
portReferenceSSH port inside ssh.
accept_hostsCanonicalAccept unknown host keys for bootstrap/demo environments.
retryCanonicalSSH connection retry policy.
sudoReferenceEnable sudo for non-root SSH users inside ssh. Root examples should not set it.
secret_envReferenceHostKit control-plane secret from an environment variable.

Bootstrap, packages, commands, files

DirectiveLevelPurpose
bootstrapCanonicalGroup host bootstrap resources without pretending they are an app service.
packageCanonicalDeclare one OS package.
packagesReferenceDeclare several OS packages with shared options.
miseCanonicalBootstrap system-wide mise and managed tools.
toolCanonicalDeclare a mise-managed tool version.
directoryReferenceDeclare an explicit directory resource. Prefer storage for service data.
fileReferenceDeclare an explicit file resource.
templateReferenceDeclare an explicit EEx-rendered file resource; secret assigns are rejected until redacted template diffs exist.
iniReferenceDeclare a structured INI config file resource with public and redacted entries.
yamlReferenceDeclare a structured YAML config file resource with ordered keyword data and public-path secret comparison.
sectionReferenceDeclare an INI section inside ini do ... end.
symlinkReferenceDeclare an explicit symbolic link resource.
sourceReferenceDeclare a source artifact/repository.
commandEscape hatchLow-level command resource.
runReferenceCommand resource helper; also systemd service-option helper in systemd scope.
gitReferenceGit command resource helper.
bashReferenceBash command resource helper.

Service conventions, storage, env

DirectiveLevelPurpose
serviceCanonicalDeclare an application/service boundary.
accountCanonicalDeclare/ref service account; account system: true derives the service user.
storageCanonicalDeclare named service storage and its directory resource.
envCanonicalDeclare a managed env file in service scope; attach it in daemon scope.
secretCanonicalAdd a secret entry inside env/dotenv or structured INI config. Use env: :redacted for existing generated secrets that must not render.
setCanonicalAdd non-secret config inside env/dotenv, provider config, or structured INI config.
service_nameReferenceReturn current service name.
service_userReferenceReturn convention-derived service user; systemd setter in systemd scope.
unit_nameReferenceReturn convention-derived systemd unit name.
pathReferenceResolve a path under a project root; inside services, conventional service roots (:source, :data, :state, :cache, :config) include the service path.
storage_volumeReferenceReturn named storage metadata.
storage_pathReferenceReturn named storage path.
writable_storage_pathsReferenceReturn paths for writable storage volumes.
backup_storageReferenceReturn storage volumes marked for backup.
dotenvReferenceDeclare a dotenv-format env file at an explicit path. Prefer contextual env when the file is service-scoped and attached to daemons.
env_fileCompatibilityOlder name for explicit dotenv resources. Prefer dotenv.

Daemons and systemd

DirectiveLevelPurpose
daemonCanonicalDeclare a persistent service unit; defaults unit name and multi-user install.
execCanonicalHuman spelling for service command.
listenCanonicalDeclare a logical listener and systemd listen metadata in daemon scope.
isolateCanonicalApply default strict service isolation as a block.
memory_maxCanonicalSet memory limit inside isolate.
writableCanonicalAllow a storage/path as writable inside isolate.
networkCanonicalNetwork policy inside isolate; currently supports :loopback.
systemd_serviceEscape hatchDeclare a raw systemd service resource.
systemd_timerEscape hatchDeclare a raw systemd timer resource.
jobReferenceSystemd service intended as a job.
scheduleReferenceSystemd timer helper.
unitEscape hatchSet raw [Unit] directives.
systemdReferenceReadiness check for a systemd unit.
serviceEscape hatchSet raw [Service] directives in systemd scope.
timerEscape hatchSet raw [Timer] directives.
installEscape hatchSet raw [Install] directives.
descriptionReferenceSet unit description.
after_unitsEscape hatchSet raw systemd After= units.
after_targetReferenceSet After= using target aliases.
wantsReferenceSet Wants= using target aliases.
requiresReferenceSet Requires= using target aliases.
service_groupReferenceSet systemd service group.
working_directoryReferenceSet service working directory.
environment_fileEscape hatchSet raw systemd env file path. Prefer env :name.
argvReferenceBuild inspectable argv from positional args and CLI options.
exec_startEscape hatchRaw systemd spelling. Prefer exec.
exec_stopReferenceSet stop command.
restartReferenceSet restart policy.
restart_secReferenceSet restart delay.
wanted_byEscape hatchSet install target. Omit for normal daemons.
hardeningReferenceApply older hardening presets. Prefer isolate.
read_write_pathsEscape hatchRaw writable paths. Prefer writable :storage.
everyCanonicalTimer calendar shorthand.
persistentReferenceTimer persistence.
on_bootReferenceTimer boot delay.
private_networkReferenceOverride private network behavior inside isolate.
network_policyReferenceExplicit network policy. Prefer network inside isolate for simple cases.

Ingress, proxy, readiness, observability

DirectiveLevelPurpose
ingressReferenceDeclare provider-neutral ingress.
serverReferenceDeclare ingress server block.
tlsReferenceSet ingress/proxy TLS mode.
routeReferenceDeclare ingress route.
proxyReferenceConfigure generic proxy or proxy resource depending on arity/context.
httpReferenceReadiness HTTP check or proxy listener depending on context.
httpsReferenceProxy HTTPS listener.
stateReferenceProxy state path.
acmeReferenceProxy ACME config.
balanceReferenceProxy balancing policy.
healthReferenceProxy health check.
drainReferenceProxy drain timeout.
targetReferenceProxy upstream target.
readyReferenceDeclare readiness checks.
endpointReferenceDeclare or reference service endpoints.
listenerReferenceResolve listener upstream. Prefer symbolic references in provider DSLs.
monitorReferenceAttach monitor checks.
observabilityReferenceGroup observability declarations.
telemetryReferenceAttach telemetry metadata/config.
logsReferenceAttach log metadata/config.
previewReferenceCompose listener, Caddy preview, monitor, telemetry, and logs.

Firewall, workspace, agents

DirectiveLevelPurpose
firewallReferenceDeclare firewall policy.
allowReferenceAdd allow firewall rule.
denyReferenceAdd deny firewall rule.
egressReferenceService egress policy.
insideReferenceDeclare checks intended to run inside a workspace sandbox.
inside_monitorReferenceAdd an inside-sandbox monitor.
agentReferenceDeclare default workspace agent service.
workspace_agentReferenceAlias for workspace agent declaration.

Caddy provider

DirectiveLevelPurpose
caddy_siteCanonicalDeclare a Caddy site. README form is caddy_site "host" do ... end.
reverse_proxyCanonicalProxy to a listener symbol, endpoint, string upstream, or upstream list.
encodeReferenceAdd Caddy encoding directive.
rootReferenceAdd Caddy root directive.
file_serverReferenceAdd Caddy file server directive.

Elixir app recipe

DirectiveLevelPurpose
elixir_appReferenceCompose source, runtime, release, env, systemd, and optional Caddy for an Elixir app.
sourceReferenceRecipe source config.
phoenixReferencePhoenix-specific recipe config.
runtimeReferenceRuntime config.
releaseReferenceRelease config.
caddyReferenceRecipe Caddy config.
ectoReferenceEcto migration/rollback config.
repoReferenceEcto repo entry.
mixReferenceMix command entry for recipe operations.