DocsBuild a custom template

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. base is what Sandbox.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') and fromAlpineImage('3.24') are shortcuts, with those defaults.
  • fromDockerfile('Dockerfile') reads an existing Dockerfile, given as a path or as its contents.

Layers

MethodWhat 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.

HelperReady 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)
OptionTypeDefault
cpuCountnumber2
memoryMBnumber1024
tagsstring[]none
skipCachebooleanfalse
onBuildLogs(entry) => voidnone

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

PlanLargest templateBuilds in parallel
Micro2 vCPU, 4 GB1
Base4 vCPU, 8 GB20
Scale4 vCPU, 8 GB40

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.