Build a custom template
A template is the image a sandbox boots from. You define one in TypeScript, build it once, then create sandboxes from it by name.
Install
The builder is the @impello/sdk/template subpath of the same package. It is
kept out of the root import so code that only creates sandboxes never loads a
Dockerfile parser.
npm install @impello/sdk
export IMPELLO_API_KEY=imp_...
Every builder call reads the key from IMPELLO_API_KEY. Keys are made at
dashboard.impello.ai/keys; see
API keys.
Define a template
Template() returns a builder. The first call says where the image comes
from, the rest add layers, and Template.build sends it off.
import { Template } from '@impello/sdk/template'
const template = Template()
.fromTemplate('base')
.aptInstall(['ffmpeg'])
.pipInstall(['numpy', 'pandas'])
.setUser('user')
.setWorkdir('/home/user')
await Template.build(template, 'my-app:v1')
There are three starting points.
fromTemplate('base')builds on top of a default template.baseis whatSandbox.create()boots with no name.fromImage('python:3.12-bookworm')starts from any Docker image.fromPythonImage('3'),fromNodeImage('lts'),fromBunImage('latest'),fromDebianImage('stable'),fromUbuntuImage('latest'),fromFedoraImage('44'),fromArchImage('latest')andfromAlpineImage('3.24')are shortcuts, with those defaults.fromDockerfile('Dockerfile')reads an existing Dockerfile, given as a path or as its contents.
Layers
| Method | What it does |
|---|---|
copy(src, dest) | Copies a file or directory from the context into the image |
runCmd(cmd) | Runs a shell command; an array runs them joined with && |
aptInstall(pkgs) | apt-get update then apt-get install -y, as root |
pipInstall(pkgs) | pip install, as root unless you pass { g: false } |
npmInstall(pkgs) | npm install; { g: true } installs globally as root |
gitClone(url, path) | Clones a repository; takes branch and depth |
setEnvs(vars) | Sets environment variables for the build steps that follow (build-time only) |
setUser(user) | Switches the user for the layers that follow |
setWorkdir(dir) | Sets the working directory |
setEnvs does not persist into sandboxes made from the template. For
variables that must exist in a running sandbox, pass envs to
Sandbox.create; see Sandbox lifecycle.
runCmd, copy and gitClone take a user option to run one layer as
somebody else. The default templates end as user in /home/user; end yours
the same way, so a command you run later lands in a home directory it can
write to.
Files and the context directory
copy paths are relative to the context directory, which defaults to the
directory of the file that called Template(). Set it with fileContextPath,
exclude files with fileIgnorePatterns, and a .dockerignore in the context
directory is read as well. A relative fileContextPath is resolved from the
working directory the build script runs in, not from the file, so pass an
absolute path.
import { join } from 'node:path'
import { Template } from '@impello/sdk/template'
const template = Template({
fileContextPath: join(import.meta.dirname, 'ctx'),
fileIgnorePatterns: ['node_modules/**'],
})
.fromNodeImage('lts')
.copy('package.json', '/app/')
.copy('src', '/app/src')
.setWorkdir('/app')
.npmInstall()
A copy source must stay inside the context directory. An absolute path, or
one that climbs out with .., throws when you define the template, before
anything is uploaded.
Start and ready commands
The start command runs once at the end of the build and the running state is snapshotted, so every sandbox made from the template starts with the process already up. The ready command decides when the build counts it as up.
import { Template, waitForPort } from '@impello/sdk/template'
const template = Template()
.fromNodeImage('lts')
.copy('server.js', '/app/server.js')
.setWorkdir('/app')
.setStartCmd('node server.js', waitForPort(3000))
Both Node examples stay root in /app, because the Node image has no user
account and /app is root-owned. To run later commands as user, add the
useradd line from the warning above and end the chain with
.setUser('user').setWorkdir('/home/user').
setStartCmd(start, ready) takes the ready check as a helper or as a shell
command that exits 0 when ready. setReadyCmd(ready) sets the check without a
start command. Both finish the definition; nothing chains after them.
| Helper | Ready when |
|---|---|
waitForPort(port) | Something is listening on port |
waitForURL(url, statusCode) | url answers with statusCode, 200 by default |
waitForProcess(name) | A process called name is running |
waitForFile(path) | path exists |
waitForTimeout(ms) | ms has passed, rounded down to whole seconds; anything under 1000 waits 1 second |
Build
Template.build(template, name, options) uploads the context files, starts the
build and waits for it to finish. name is name or name:tag, up to 128
characters.
import { Template } from '@impello/sdk/template'
const template = Template().fromTemplate('base').aptInstall(['ffmpeg'])
const info = await Template.build(template, 'my-app:v1', {
cpuCount: 2,
memoryMB: 2048,
onBuildLogs: (entry) => console.log(entry.toString()),
})
console.log(info.templateId, info.buildId, info.tags)
| Option | Type | Default |
|---|---|---|
cpuCount | number | 2 |
memoryMB | number | 1024 |
tags | string[] | none |
skipCache | boolean | false |
onBuildLogs | (entry) => void | none |
cpuCount and memoryMB are the size of every sandbox created from the
template, not just of the build. The minimum is 1 vCPU and 128 MB. Each log
entry carries a timestamp, a level of debug, info, warn or error,
and a message; toString() puts the three on one line.
Layers are cached between builds, and a copy layer is rebuilt when the files
under it change. skipCache: true rebuilds everything. .skipCache() on the
builder skips the cache for every instruction after it.
A failed build throws BuildError carrying the message the build failed with;
see Errors.
Limits
| Plan | Largest template | Builds in parallel |
|---|---|---|
| Micro | 2 vCPU, 4 GB | 1 |
| Base | 4 vCPU, 8 GB | 20 |
| Scale | 4 vCPU, 8 GB | 40 |
A template cannot be built larger than the sandbox your plan allows. The parallel-build limit is its own ceiling, counted apart from the number of sandboxes you may run at once. Build time is not metered; only sandbox runtime draws down credit. See Limits and pricing.
Build in the background
Template.buildInBackground returns as soon as the build is requested. Poll
Template.getBuildStatus with the ids it gives you.
import { Template } from '@impello/sdk/template'
const template = Template().fromTemplate('base').aptInstall(['ffmpeg'])
const info = await Template.buildInBackground(template, 'my-app:v1')
let logsOffset = 0
for (;;) {
const status = await Template.getBuildStatus(info, { logsOffset })
for (const entry of status.logEntries) console.log(entry.toString())
logsOffset += status.logEntries.length
if (status.status === 'ready' || status.status === 'error') {
for (;;) {
const tail = await Template.getBuildStatus(info, { logsOffset })
if (tail.logEntries.length === 0) break
for (const entry of tail.logEntries) console.log(entry.toString())
logsOffset += tail.logEntries.length
}
if (status.status === 'error') throw new Error(status.reason?.message)
break
}
await new Promise((resolve) => setTimeout(resolve, 2000))
}
status is one of building, waiting, ready or error. A call returns at
most 100 log entries, so a terminal status does not mean you have them all.
Keep polling at the advanced offset until a call comes back empty, as the loop
above does. Drop that drain and a build that logs more than 100 lines after
your last poll hides its tail, including the failing step's output on error.
logsOffset skips the ones you already have. On error, reason holds the
message, the failing step and its logEntries.
Tags
A tag points a name at one build. Put the tag in the name, or list several in
tags, then move them between builds without rebuilding.
import { Template } from '@impello/sdk/template'
const template = Template().fromTemplate('base').aptInstall(['ffmpeg'])
await Template.build(template, 'my-app', { tags: ['v2', 'staging'] })
await Template.assignTags('my-app:v2', 'production')
await Template.removeTags('my-app', 'staging')
for (const tag of await Template.getTags('my-app')) {
console.log(tag.tag, tag.buildId, tag.createdAt)
}
assignTags(targetName, tags) takes the source build as name:tag.
assignTags and removeTags each accept one tag or an array.
getTags(templateId) takes a name or an id and returns every tag.
Private registries
fromImage takes registry credentials; fromAWSRegistry and fromGCPRegistry
take the provider's own.
import { Template } from '@impello/sdk/template'
const fromRegistry = Template().fromImage(
'registry.example.com/team/app:1.4',
{
username: process.env.REGISTRY_USER!,
password: process.env.REGISTRY_PASSWORD!,
}
)
const fromEcr = Template().fromAWSRegistry(
'123456789012.dkr.ecr.eu-central-1.amazonaws.com/app:1.4',
{
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
region: 'eu-central-1',
}
)
const fromGar = Template().fromGCPRegistry(
'europe-west3-docker.pkg.dev/project/repo/app:1.4',
{ serviceAccountJSON: 'service-account.json' }
)
serviceAccountJSON is a path relative to the context directory, or the
parsed JSON as an object. Credentials travel with the build request; keep them
out of the files you copy into the image.
List, check and delete
Template.exists(name) answers true for a name that is taken, including a
default template and a name another team owns.
import { Template } from '@impello/sdk/template'
const taken = await Template.exists('my-app')
console.log(taken)
Listing and deleting have no SDK method. Do them on the dashboard at dashboard.impello.ai/templates/list, or over HTTP.
curl "https://api.sandbox.impello.ai/v2/templates" \
-H "X-API-Key: $IMPELLO_API_KEY"
curl -X DELETE "https://api.sandbox.impello.ai/templates/$TEMPLATE_ID" \
-H "X-API-Key: $IMPELLO_API_KEY"
GET /v2/templates is paged. It takes limit and nextToken query
parameters and returns an X-Next-Token header when more results exist; pass
that token back as nextToken until the header is absent.
TEMPLATE_ID is the templateId that Template.build returned. Both routes
take the same X-API-Key header as the rest of the API.
Create a sandbox from it
Pass the name to Sandbox.create. Everything else about the sandbox is the
same; see Sandbox lifecycle.
import { Sandbox } from '@impello/sdk'
const sandbox = await Sandbox.create('my-app')
const result = await sandbox.commands.run('ffmpeg -version')
console.log(result.stdout)
await sandbox.kill()Next: Sandbox lifecycle.