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; sites go away by pruning, not by a delete command.
How routing works
One public listener answers every request to your slice, 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, as in
/#/users/42. Browsers never send the fragment, so every request is for/and lands on your oneindex.html. - A directory per route: pre-render (static export), so
users/42/index.htmlgenuinely exists in the folder you deploy. 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. Sites are removed by pruning: drift project deploy deploys every site listed under canvas.sites in your Driftfile, then deletes every site the slice is holding that the Driftfile did not name.
A site missing from the Driftfile is destroyed by the next deploy.
drift canvas deploy ./admin --route /admin and never added to the Driftfile is destroyed by the next drift project deploy. Declare every site you mean to keep.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. Declare canvas.canvas_size in your Driftfile to size the slice 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:
canvas_size: 100MB # optional: total Canvas storage for the slice
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:
name: my-app
backbone:
nosql:
- { name: items, size: 50MB }
canvas:
sites:
- ./frontend/buildThe functions need no listing, because flat files under atomic/ are discovered automatically. Stored Backbone resources carry their own size, which is both the billing driver and the enforced quota, so nosql, blobs and sql entries are refused without one. Queues, cache entries and secrets take no size at all, so a queue map that carries any key besides name is refused.
Your 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:
alice.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.