Canvas
Canvas serves your static files (HTML, CSS, JavaScript, images) and forwards any request under /api/* to your Atomic functions. Your frontend and your backend live at the same origin, so the browser calls /api/… with no CORS headers, no proxy config, and no separate API domain to manage.
There is no CORS to configure.
/api/items the way it would fetch a local file: same host, same TLS, one deploy. There is no CORS preflight to configure because there is no second origin.Deploy a site
# serve a folder at the root
drift canvas deploy ./my-site
# mount a folder under a sub-path
drift canvas deploy ./admin --route /adminCanvas zips the folder, uploads it, and serves it over HTTPS at your slice's URL. Re-deploying the same route replaces that site's files wholesale. deploy is the only drift canvas subcommand, and no command takes a site away again — see Removing a site.
How routing works
Your slice answers every request on one public URL, and it checks a short list of reserved prefixes before it looks for a file. Order matters:
| Request | Handled by |
|---|---|
GET /realtime/<channel> | Backbone Realtime: the WebSocket upgrade, matched first |
GET /api/items | Atomic → the http=get:items function |
POST /api/items | Atomic → the http=post:items function |
/trigger/… | Atomic → the trigger surface |
GET /health, GET /ready | The slice's own liveness probes |
/deploy, /prune, /admin/sites | Canvas's operator surface: what the CLI's deploy path calls |
GET / | Canvas → index.html |
GET /styles.css | Canvas → the static file |
Everything that is not one of those prefixes is served as a static file. The reserved names belong to the platform, so a site mounted at / cannot serve its own /health or /deploy, and a wrong method on /deploy or /prune answers 405 rather than falling through to your files. A site mounted at a sub-route is unaffected: /blog/health is an ordinary file.
The /realtime/ intercept is why a browser subscribes at wss://<your-slice>/realtime/<channel>, and that URL is a Canvas route handed to Backbone's hub. See Backbone for the channel protocol.
Response headers
Every static response carries a fixed header set. There is no flag, Driftfile key or API to change any of it:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()The CSP confines every subresource to your own origin.
<script>, a Google Fonts stylesheet (and its font files, which fall back to default-src 'self'), a remote image, and any fetch, XHR or WebSocket to another host are blocked by the browser. Vendor what you need into the folder you deploy. X-Frame-Options: DENY also means a Canvas site cannot be embedded in an iframe.Your own /api/* calls and /realtime/ socket are same-origin, so connect-src 'self' allows them. 'unsafe-inline' is granted to scripts and styles, so an inline <script> or a style attribute works. Content types come from the file extension, and Canvas answers a conditional If-Modified-Since request with 304.
Single-page apps
Deploy your framework's build output, the contents of dist/ or build/, like any other folder. Two rules about how paths resolve decide whether a client-routed app survives a refresh.
Deep links need a real file
There is no history-API fallback. A path with no file extension is treated as a directory and index.html is appended to it, so GET /users/42 resolves to users/42/index.html inside your site. A Vite or CRA build does not contain that file, and the answer is 404. The two ways to keep deep links working:
- Hash routing Paths take the form
/#/users/42. Browsers never send the fragment, so every request is for/and lands on your oneindex.html. - Pre-rendering A static export writes
users/42/index.htmlinto the folder you deploy, so the file genuinely exists. This only covers routes you can enumerate at build time.
The injected <base href>
When Canvas serves an HTML file that contains a literal lowercase <head> and no <base>, it inserts a <base href> pointing at the directory of the request URL, not at the route the site is mounted on. For a site deployed with --route /blog:
| Request | Injected |
|---|---|
/blog | <base href="/"> |
/blog/ | <base href="/"> |
/blog/index.html | <base href="/blog/"> |
A sub-route resolves its assets against the origin root.
/blog or /blog/, and both get <base href="/">, so relative asset paths resolve against the origin root and reach the root site or a 404. Build with the base path set to your route (--base=/blog/ in Vite, homepage in CRA) so the emitted URLs are absolute, or write your own <base href="/blog/">, which Canvas leaves alone.Injection is skipped when the document contains no lowercase <head> token, and when the bytes contain <base anywhere. A site mounted at / gets a base href that matches its own directory, so it needs none of this.
Multiple sites
A slice can host several sites, each at its own route. The longest matching route wins, so a site at / never shadows one at /blog whatever order you deployed them in:
drift canvas deploy ./marketing # at /
drift canvas deploy ./blog --route /blog # at /blog
drift canvas deploy ./admin --route /admin # at /adminEach route becomes a slug that names the site's directory on the slice: / is default, /blog is blog, /admin/portal is admin-portal.
Removing a site
There is no drift canvas delete, and drift file apply does not remove one either. A site the Driftfile does not name is left serving: a slice can host more than one project, and a deploy cannot tell a site that is not yours from one you meant to drop, so it does neither.
What apply does instead is report it. At the end of a run it names every live resource the manifest does not, canvas sites among them:
· Live on this slice, and not named in this Driftfile:
canvas site admin
still served — no drift command removes a siteSo a site deployed by hand with drift canvas deploy ./admin --route /admin and never added to the Driftfile keeps serving on its route, and is named on every apply until you deal with it.
Taking a site away is currently a gap, and the report says so rather than pretending otherwise.
Limits
- Value
- 100 MB
- Past it
413, nothing is deployed
- Value
- 50 MB on the free tier
- Past it
413, nothing is deployed
- Value
- 10,000
- Past it
- Extraction stops; the deploy reports success
The size check is against the compressed ZIP of the deploy you are making, not the total across every site on the slice. Raise the slice's site storage in the slice's shape for more.
The file ceiling is the one that fails quietly.
404 for whatever did not make it in. Count the files in the folder before shipping a large asset tree.In a Driftfile
Declare your sites alongside everything else and ship them with one command:
canvas:
sites:
- ./site # mounted at /
- dir: ./blog
route: /blog # mounted at /blogA full-stack app, meaning a frontend, an API, and its data, is three sections of one file:
slice: my-app
atomic:
functions:
- route: items
method: get
handler: GetItems
backbone:
nosql:
- slot: items
canvas:
sites:
- ./frontend/buildEvery function is listed. An entry under atomic.functions is what turns a callable in your source into an endpoint; a function the file does not name is ordinary code and never gets a route. That entry is also the billing unit, which is why naming it is the act — see Write a function.
drift file applyYour build is served at /, your functions answer at /api/*, and your data lives in Backbone, all on the same origin. See the Driftfile reference for every option.
Custom domains
Every slice answers at <username>-<slice>.ondrift.eu with TLS handled for you, and you can point your own hostname at it:
drift slice domain add yoursite.com # prints the DNS records to add
drift slice domain verify yoursite.com # re-checks the TXT, then issues the certificate
drift slice domain list # host, status, wildcard, detail
drift slice domain remove yoursite.com # also drops its certificate and routingGetting started walks the full flow.
A subdomain per tenant
--wildcard routes every subdomain of a host to the same slice:
drift slice domain add imprente.com --wildcardalice.imprente.com, bob.imprente.com and every other subdomain reach the same Canvas site and the same functions. Drift delivers the request; the app decides what the subdomain means. The match strips one label, so a.b.imprente.com is not covered by a wildcard on imprente.com.
This is routing, not a wildcard certificate.
Host to the internal backend before proxying. Read location.hostname in the browser and send the tenant explicitly, in the path, the body, or a header of your own.