Reference · docs/config.md
dhtml.config #
dhtml.config sits at the root of every project. It is a plain text file in an INI-style format (decision D1): sections in [brackets], settings as key = value, comments starting with #.
The file is checked on every upload. Every problem is reported with the file, the line, and a hint. Unknown keys get a "did you mean" suggestion.
Format #
# a comment on its own line
[server]
port = 3000 # a comment after a value (needs a space before the #)
routing = files- Section and key names are lowercase words. Values are plain text.
- A
#starts a comment when it is the first character on a line, or when it follows a space or tab.item#1andk=#abckeep their#;k = #abcis an empty value followed by a comment. - To keep a
#after a space, or leading and trailing spaces, put the value in double quotes:/h = "text a # b". Inside quotes the only escapes are\"and\\. - A key may appear once per section, and a section once per file. Repeating either is an error that names the first line.
- Files are UTF-8 text. A byte order mark and Windows line endings are fine. Non-text data, control characters (a CR is only allowed as part of CRLF; DEL and the C1 range are refused), lines over 8192 bytes, and files over 1 MiB are rejected.
- At most 20 problems are reported per file per run, followed by a line saying more were found.
[app] (required) #
| Key | Default | Meaning |
|---|---|---|
name | none, required | App name. Letters, digits, ., _, -; starts with a letter or digit; up to 64 characters. |
version | 0.1.0 | App version as major.minor.patch, each part a number up to 999999 with no leading zeros. dhtml adds the build number of every upload. |
environment | production | development or production. |
url | none | The address of your site, like https://example.com. Links in email (confirm an account, reset a password) are built from it, never from the request, so nobody can aim them elsewhere. https; no path, query or sign-in. Needed for registration = on. Also readable in pages as config.app.url. |
navigation | off | fast makes page changes happen without a full load, through a small script the server adds to every page; off changes nothing. See Fast navigation. |
[ai] (optional) #
Present only if the app uses AI calls (<d:ai> in an action, see templates.md).
| Key | Default | Meaning |
|---|---|---|
provider | none, required in this section | anthropic (the Anthropic Messages API), openai (the OpenAI chat API, and every service that speaks it: OpenRouter, Groq; set base_url), or fake (no key, no network: it answers with a fixed sentence built from the question, for tests and for trying the pages; the check warns when it is used on a live site). |
model | claude-sonnet-5-5 for anthropic; required for openai | Model name, exactly as the provider spells it: up to 100 letters, digits and - _ . : /. |
base_url | the provider's own address | Another address for the API: a proxy or OpenRouter. It must be https. No user name, password, query or fragment. Redirects from the provider are never followed, so the key cannot be forwarded. |
max_tokens | 1024 | The most a model may write per call, 1 to 32768. A <d:ai max_tokens="..."> can change it for one call. |
timeout | 30 | Seconds before a call is given up, 1 to 300. A <d:ai timeout="..."> can change it for one call. |
key | none | Where the API key lives, always written as env:NAME, for example env:ANTHROPIC_API_KEY. The key itself goes in the Secrets section of your site on dhtml.net. openai with a base_url on this machine needs no key. A literal key here is an error. Errors on [ai] lines never repeat the value, since people paste secrets into the wrong field. |
[auth] (optional) #
Accounts and sign-in. The tags that use them are in templates; the design is decision D19.
| Key | Default | Meaning |
|---|---|---|
registration | off | on lets visitors make their own account with <d:register />. It needs [mail] and url in [app]: an account exists only once the emailed link is confirmed. With it off, visitors cannot make accounts. |
admin_email | none | env:NAME: the secret that holds the address of the site's owner. The owner opens /_dhtml/claim, gets a one-time link by mail at that address and nowhere else, chooses a password and becomes the admin (see Claiming the owner account). Needs [mail] and url in [app], and the secret must be set. |
magic_login | off | on lets visitors sign in with an emailed link: <d:magic /> mails it, <d:magiclogin /> uses it (see templates.md). It needs [mail] and url in [app]. |
min_password | 10 | Shortest password accepted, 8 to 200 bytes. The longest is always 1000. |
login_page | /login | Where visitors without an account go from a protected page. A path on this site. |
confirm_page | /auth/confirm | The page your app serves for the "confirm your address" link. It shows a button that posts to a <d:confirm /> action. |
reset_page | /auth/reset | The page your app serves for the "reset your password" link. It shows a form that posts to a <d:reset /> action. |
magic_page | /auth/magic | The page your app serves for the sign-in link. It shows a button that posts the token to a <d:magiclogin /> action. |
[mail] (optional) #
How dhtml sends the mail that accounts need. Plain text only, built by dhtml.
| Key | Default | Meaning |
|---|---|---|
transport | none, required in this section | smtp, or log (writes the mail to the console instead of sending it; for trying things out only: a live site refuses it). |
host | none, required for smtp | The mail server's name, like smtp.example.com. |
port | 587 (465 with security = tls) | 1 to 65535. |
security | starttls | starttls or tls. Mail is always encrypted; there is no plain option. |
username | none | Sign-in name for the server. Goes with password. |
password | none | Written as env:NAME, like env:MAIL_PASSWORD, with the password in the Secrets section of your site on dhtml.net. A literal password here is an error, and errors never repeat the value. |
from | none, required for smtp | The sender address, like noreply@example.com. Just the address, no display name. |
from_name | none | The name shown before the sender address, like My Shop (at most 60 characters; no quotes, brackets or backslashes). Mail then arrives from "My Shop" instead of the bare address. Needs from. Note that some providers, Gmail among them, replace the name with the one set on the account unless the address is a "send as" alias. |
notify | none | The one address <d:notify /> mails (a contact form, say). Just the address. Needed only by an action that has that step. See templates.md. |
limit_per_hour | 60 | The most mail the site sends to visitors with <d:mail /> in an hour, 1 to 100000. A mail over the limit is dropped and logged; the visitor's request still succeeds. There is also a fixed limit of 3 mails to one address in a day. See templates.md. |
[protect] (optional) #
Paths that need a signed-in user. Each line is path = auth or path = admin; a path ending in /* covers everything under it.
[protect]
/account = auth
/admin/* = adminVisitors who are not signed in are sent to login_page with ?next= set to where they were going; paths under /api answer 401 or 403 instead. Listing a path twice is an error. /_dhtml cannot be protected.
[http] (optional) #
What <d:http> may call (decision D21). An app can reach nothing that is not listed here, and the check on every upload shows every call a project makes with the host it goes to.
| Key | Default | Meaning |
|---|---|---|
allow | none | Comma-separated host names: api.stripe.com, *.example.org. *.example.org means every host under it, not example.org itself. Names only: no scheme, port or path. |
private | off | on lets calls reach this machine and private networks, and use plain http there. Off, a host name that points at a private, loopback, link-local (cloud metadata) or reserved address is refused at connect time, whatever DNS says. Turn it on only for a service that really runs next to the app. |
Calls are https to the standard port, follow no redirects, and use no proxy from the environment. Refused addresses include loopback, private (10/8, 172.16/12, 192.168/16, fc00::/7), link-local (169.254/16, which holds the cloud metadata services, and fe80::/10), multicast, carrier-grade NAT, the documentation and benchmarking ranges, and the IPv6 forms that carry an IPv4 address inside (IPv4-mapped, NAT64, 6to4, Teredo, IPv4-compatible). The check is made on each address a name resolves to, and the connection is made only to one that passed.
[webhooks] (optional) #
Signed endpoints that other services call (decision D21). One line each: name = scheme env:SECRET [options]. An action uses one with <d:action webhook="name">; the signature is checked before anything else is read, and a call that fails the check changes nothing.
[webhooks]
stripe = stripe env:STRIPE_WEBHOOK_SECRET
github = github env:GITHUB_WEBHOOK_SECRET
shop = hmac env:SHOP_SECRET header=X-Shop-Signature encoding=base64 prefix=sha256=| Scheme | Checks | Options |
|---|---|---|
stripe | The Stripe-Signature header: HMAC-SHA256 of timestamp.body, with the timestamp no older than tolerance seconds (default 300) | tolerance=N, 1 to 3600 |
github | The X-Hub-Signature-256 header: sha256= and the hex HMAC-SHA256 of the body | none |
hmac | A header you name, with the HMAC-SHA256 of the body in hex (default) or base64, after an optional prefix | header=NAME (required), encoding=, prefix= |
The secret is written as env:NAME and kept in the Secrets section of your site on dhtml.net; a literal secret is an error, and errors on these lines never repeat the value. An upload is refused when an action names a webhook that is not listed or whose secret is not set.
[cors] (optional) #
Which other sites a browser may call from (decision D23). Without this section no site can read the answers of your actions: a browser refuses to show them. One line per path: path = origin, origin.
[cors]
/api/v1/* = https://app.example.com, https://admin.example.com
/api/public/ping = https://app.example.com- The path is exact (
/api/v1/notes) or ends in/*for everything below it. An exact line wins over a wildcard, and the longest wildcard wins over a shorter one. - An origin is one exact site:
https://app.example.com, with a port if it is not the standard one, no path, no trailing slash, lower case. Onlyhttpsis accepted.*andnullare errors, whatever the line. - Only actions called with a key (
apikey, see templates.md) can be listed. An upload is refused if a line covers no such action, since a cookie-based action must never be readable from another site. Credentials are never allowed: dhtml never sendsAccess-Control-Allow-Credentials, and a key action reads no cookie. - What a listed origin gets: the answer carries
Access-Control-Allow-Originwith that origin (andVary: Origin), failures included, and the preflight a browser sends before a call with anAuthorizationheader is answered204with the action's method, the headersAuthorizationandContent-Type, andMax-Age: 600. Any other origin gets no CORS header (and403to a preflight). CORS is a rule for browsers: a program can call without it, with its key.
[channels] (optional) #
The channels that pages can listen to, for live updates (see Live updates). A channel that is not listed does not exist: nobody can listen to it, and an upload is refused if an action has a <d:publish /> to it.
[channels]
board = auth # one channel, for signed-in visitors
news = public # anyone
staff = admin # admins
room-* = auth # every name starting with room-
inbox:* = user # inbox:42 is for user 42 only- The name is lower-case letters, digits and
- _ : ., up to 64 characters. A*at the end stands for every name that starts with the text before it; the longest matching line wins, and an exact line wins over a prefix. - The rule is
public,auth(signed in),admin, oruser.userneeds a name ending in*; the visitor's own id is added to it, so only that person can listen toinbox:42. It is the way to give each person a private channel. - Publishing is done by actions (
<d:publish />), which have their own protections (auth,admin,ratelimit). The rules here say only who may listen. - Limits. One visitor (or address) may hold 5 connections; the process holds 1000; a message is at most 4096 bytes; a listener that falls behind is dropped and its browser reconnects. A connection ends after 15 minutes and is opened again by the browser, which asks the rule and the session again; the session is also asked at every 25-second ping, so a sign-out or a ban ends an open stream within one ping. A channel name made from a value (
room-{{ id }}) must fall under the same line as its fixed start; a value that turns it into another listed channel sends nothing. - One process. Channels live in the program's memory. Several copies of the app behind one address would not share them.
[routes] #
Adds to or overrides file-based routing. Each line is path = target.
[routes]
/login = pages/auth/login.dhtml
/old-blog = redirect /blog
/docs = redirect https://example.com/docs
/health = text OK
/ping = json {"ok": true}Paths start with / and use letters, digits and - . _ ~ / : only. . and .. segments, doubled slashes, and query strings are errors. /a and /a/ are the same route, so listing both is a duplicate.
Targets are one of:
| Target | Meaning |
|---|---|
pages/....dhtml or api/....dhtml | A file under server/. Must end in .dhtml, use /, and stay inside pages/ or api/. Layouts and partials cannot be routes. Every folder and file name uses letters, digits and - _ . [ ] only, so it means the same on every OS: no spaces, no : (Windows alternate data streams), no names starting or ending with a dot, and no Windows device names such as CON or NUL. [name] marks a URL parameter. |
redirect <target> | Redirect to a path like /blog or an http:// or https:// address. Protocol-relative (//host) and other schemes such as javascript: are refused. |
text <body> | Respond with plain text. |
json <body> | Respond with JSON. The body must be valid JSON. |
[oauth] (optional) #
Sign in with Google (decision D41). Off unless google_client_id is written.
[oauth]
google_client_id = 123-abc.apps.googleusercontent.com
google_secret = env:GOOGLE_CLIENT_SECRET| Key | Default | Meaning |
|---|---|---|
google_client_id | none | The client id from the Google Cloud console (OAuth client of type "Web application"). Needs url in [app]. |
google_secret | none | env:NAME: the secret, in the Secrets section of your site on dhtml.net, that holds the client secret. The secret itself is never written here and never repeated in an error. |
after | / | A local path where a person lands after signing in, when no other page was asked for. |
In the Google console, add https://YOUR-SITE/_dhtml/oauth/google/callback (your [app] url plus that path) as an authorized redirect URI. A page links to /_dhtml/oauth/google (optionally ?next=/some/page) to start; {{ config.oauth.google }} is true when it is set up. Only a verified Google address signs in. An account with that address is linked to the Google account and signed in; with [auth] registration = on a missing account is made. A suspended account is refused. The migrations make the table that remembers the link.
[lang] (optional) #
Several languages (decision D58): the words of the app live in one file per language, server/lang/<code>.lang, and a page says {{ "nav.home" | t }}. Without [lang] nothing changes. The format of the files and the t filter are in templates.md.
[lang]
default = en
available = en, nl, de
detect = path, cookie, accept| Key | Default | Meaning |
|---|---|---|
default | none (required) | The language that is shown when nothing else decides, and that fills in a text another language lacks. A lower-case code: en, nl, pt-br. |
available | the default only | The languages the app offers, separated by commas (at most 20), each with a file server/lang/<code>.lang. |
detect | cookie, accept | The ways to choose the language of a request, tried in this order, the first that decides wins: path (/nl/about is the page about in Dutch; the default language has no prefix), host (nl.example.com or example.nl), cookie (a link with ?lang=nl sets the cookie lang, which is read on the next requests), accept (the Accept-Language header of the browser). |
missing | warn | error makes the check on every upload fail when a language lacks a key the default language has. |
.env #
Secrets (API keys, passwords, signing keys) are set in the Secrets section of your site's page on dhtml.net: one name and one value each. They are never in the project zip and never served. Settings point to them as env:NAME and never hold a secret themselves. Messages name a variable only when it is written the way variables are (capitals, digits and _, like MAIL_PASSWORD, at most 40 characters); anything else written after env:, such as a password pasted there by mistake, is not repeated in any message, log or page.
- Names use letters, digits and
_, and do not start with a digit. - Values are never expanded:
$HOMEstays$HOME. - Error messages name the setting but never repeat the value.
