Creating functions
Deploy functions to Volcano. Volcano supports Node.js, Python, and Ruby runtimes.
Deploy functions to Volcano. Volcano supports Node.js, Python, and Ruby runtimes.
Supported runtimes
| Language | Runtimes | Default handler |
|---|---|---|
| Node.js | nodejs24.x, nodejs22.x | handler |
| Python | python3.14, python3.13, python3.12, python3.11, python3.10 | handler |
| Ruby | ruby4.0, ruby3.4, ruby3.3 | handler |
python3.10 is deprecated: Python 3.10 reached end of life in October 2026. It still deploys, and Volcano will remove it before 2027-08-31. Move functions to a newer Python runtime.
Your code is automatically packaged with a standard filename (index.js, main.py, or main.rb). You only need to specify the function name.
Writing functions
Node.js
// index.js
exports.handler = async (event) => {
console.log('Event:', JSON.stringify(event));
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Hello from Node.js!',
data: event,
}),
};
};TypeScript
Write a Node.js function in TypeScript by putting its handler in index.ts,
index.mts, index.cts, or index.tsx. Volcano compiles it to JavaScript
during the build, after it installs dependencies.
// index.ts
type Event = { name?: string };
export const handler = async (event: Event) => ({
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `Hello, ${event.name ?? 'TypeScript'}!` }),
});How the build compiles it:
- It removes types without checking them, so a type error doesn't fail the
deploy. Run
tsc --noEmitin your own CI to catch type errors. - It bundles the files the handler imports, including imports through
pathsin atsconfig.jsondeployed with the function. Packages frompackage.jsonaren't bundled; they load from the dependencies the build installs. .mtscompiles to an ES module and.ctsto CommonJS..tsand.tsxfollow Node.js: an ES module when the nearestpackage.json, looking up to the one the function's dependencies install from, sets"type": "module", CommonJS otherwise. Top-levelawaitandimport.metaneed an ES module.- JSX compiles as JSX in
.tsxhandlers describes. - JavaScript wins. With the upload API, when Node.js can
load the handler without compiling, the function deploys that and its
TypeScript isn't compiled: an
index.js,index.mjs, orindex.cjsfile, an extensionlessindexfile, anindex/directory holding apackage.jsonor anindex.js,index.json, orindex.node, an installed package namedindex, or anindexmodule in aNODE_PATHdirectory inside the function. Anypackage.jsoncounts, whatever itsmainnames. An emptyindex/directory, or one with none of these, doesn't count, and neither does aNODE_PATHdirectory outside the function. Commit compiled JavaScript when you need a compiler feature the build skips, such as decorator metadata. - Git deploy reads only
index.jsorindex.mjsas a JavaScript entry besideindex.ts. A shippedindex.cjs, extensionlessindexfile orindex/directory that counts beside it deploys neither, and the push fails withtypescript_not_compiled. - Keep one of
index.ts,index.mtsandindex.cts. A function with more than one fails to deploy.index.tsxcompiles only when none of them is there, so besideindex.tsthe.tsfile deploys. - TypeScript never changes what a function directory already deploys in
Git deploy: a JavaScript file,
main.pyormain.rbbesideindex.tsstays the entry, with its runtime.
Errors name your TypeScript. The build writes a source map beside the compiled
handler and turns on Node.js source maps before the handler loads, so a stack
trace in your function's logs, in Issues, and in your
own error.stack names the .ts or .tsx file and line, such as
lib/billing.ts:4, by its path under the function's root. Frames in installed
packages stay as the package ships them.
- The compiled handler is
index.cjsorindex.mjs, and it loadsindex.bundle.cjsorindex.bundle.mjs, which holds your code. Your code's__dirnameandimport.meta.urlstay the directoryindex.tsis in. - Source maps cost CPU when a stack trace is read; a stack nothing reads costs nothing. The first one in each running instance parses the map, and later ones cost about three times an unmapped stack. Measured on Node.js 24 with a 98 KiB map, the first stack took about 0.6 s at 128 MB of memory and 0.05 s at 1024 MB, and the parsed map used about 12 to 17 MB more memory. The cost grows with the map, so a map over 256 KiB isn't written; a map at that limit costs about 1 s at 128 MB, extrapolated from measurements of 98 KiB and 1.9 MiB maps.
- Set
NODE_OPTIONS=--no-enable-source-mapsto turn source maps off, orNODE_OPTIONS=--enable-source-mapsto turn them on for installed packages too; Volcano leaves your setting as it is. - A handler compiles without a source map, so its frames name
index.cjsorindex.mjs, when its map would pass 256 KiB, when a file in the function already has the bundle's or the map's name, when it bundles a file from outside the function's directory or a JavaScript file whose own source map points outside it or to a URL, or when an ES module handler re-exports everything from an installed package, asexport * from "package"does. The build log says which.
Volcano compiles TypeScript deployed with Git deploy,
the upload API or the CLI, in the cloud and
in local mode. The CLI uploads .ts, .mts and .cts handlers from version
0.76.0 and .tsx handlers from version 0.78.0; older versions skip those
files, so upgrade first. See
CLI project layout for the TypeScript files it finds.
JSX in .tsx handlers
A handler in index.tsx can render JSX, such as an HTML page or email. The
build compiles JSX with React's automatic runtime, so a component needs no
import React. Add react, and any renderer you call, to the function's
package.json: the build doesn't bundle them.
// index.tsx
import { renderToStaticMarkup } from 'react-dom/server';
const Greeting = ({ name }: { name: string }) => <h1>Hello, {name}!</h1>;
export const handler = async (event: { name?: string }) => ({
statusCode: 200,
headers: { 'Content-Type': 'text/html' },
body: renderToStaticMarkup(<Greeting name={event.name ?? 'TSX'} />),
});{
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}- A
tsconfig.jsonorjsconfig.jsonin the handler's directory or above it sets how JSX compiles for the files it covers, including throughextends. Set"jsx": "react"forReact.createElement,"jsx": "react"with"jsxFactory": "h"for a custom factory, or"jsxImportSource": "preact"for Preact's runtime. - In a
.tsxhandler,"jsx": "preserve"and"jsx": "react-native"compile with the automatic runtime, because Node.js can't run raw JSX. AjsxFactorytakes effect only with"jsx": "react". - In a
.tsxhandler, a/** @jsx h */or/** @jsxFrag Frag */comment needs a/** @jsxRuntime classic */comment before it, or a tsconfig"jsx": "react". A JSX comment the build would ignore fails the build. - A
.ts,.mtsor.ctshandler compiles JSX in the.tsxfiles it imports as it always has:React.createElement, unless a tsconfig or a comment says otherwise.
Python
# main.py
import json
def handler(event, context):
print(f'Event: {json.dumps(event)}')
return {
'statusCode': 200,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps({
'message': 'Hello from Python!',
'data': event
})
}Ruby
# main.rb
require 'json'
def handler(event:, context:)
puts "Event: #{event.to_json}"
{
statusCode: 200,
headers: { 'Content-Type' => 'application/json' },
body: {
message: 'Hello from Ruby!',
data: event
}.to_json
}
endCLI project layout
When you deploy with the Volcano CLI, functions are discovered under:
volcano/functions/The CLI supports both single-file and directory-based functions.
Single-file functions
These are the simplest option. The filename becomes the function name.
volcano/functions/hello.js
volcano/functions/greet.ts
volcano/functions/notes.py
volcano/functions/worker.rbExamples:
volcano/functions/hello.js-> function namehellovolcano/functions/greet.ts-> function namegreet(also.mts,.ctsand.tsx)volcano/functions/notes.py-> function namenotesvolcano/functions/worker.rb-> function nameworker
Directory-based functions
Use a directory when a function needs multiple files.
volcano/functions/api/index.js
volcano/functions/checkout/index.ts
volcano/functions/jobs/main.py
volcano/functions/reports/main.rbThe directory name becomes the function name.
Discovery rules:
- The entry is the runtime's handler file at the top of the directory:
index.js,main.pyormain.rb. A directory with none of them, and no TypeScript index, isn't a function. - A TypeScript directory function needs its handler at the top of the
directory as
index.ts,index.mts,index.ctsorindex.tsx. Anindex.js,main.pyormain.rbbeside it deploys instead; other files, such ashelpers.js, don't.index.tsxis the entry only when none ofindex.ts,index.mtsandindex.ctsis there.
Examples:
volcano/functions/api/index.js-> function nameapivolcano/functions/checkout/index.ts-> function namecheckoutvolcano/functions/jobs/main.py-> function namejobs
TypeScript needs CLI 0.76.0 or later, and .tsx needs CLI 0.78.0 or later.
The CLI doesn't find declaration files such as types.d.ts. It doesn't find a
handler nested below the function's directory, such as
volcano/functions/checkout/src/index.ts.
Shared code
Any file or directory whose basename starts with _ is treated as shared code and
packaged automatically for every function.
Examples:
volcano/_shared.js
volcano/functions/_helpers/This is useful for shared utilities without requiring each function to duplicate them.
Once deployed, shared code sits next to the function's entry file under its own
name, whether it came from volcano/ or volcano/functions/. Import it from
there in every function, including directory functions. From the entry file:
| Runtime | Import volcano/functions/_helpers/db.* as |
|---|---|
| Node.js | require('./_helpers/db') or from './_helpers/db.js' |
| Python | from _helpers.db import connect |
| Ruby | require_relative '_helpers/db' |
A file in a subdirectory of a directory function, such as
volcano/functions/api/lib/orders.js, adds one ../ per level in Node.js and
Ruby: require('../_helpers/db'). Python's import is the same from any file.
A directory function's entry file that imports ../_helpers/db works in your
repository but fails once deployed, in Node.js with Cannot find module. A
Git deploy warns about it in the function's
build log, with the file, the line, and the import
to use instead.
The same goes for a single-file function and shared code directly under
volcano/: volcano/functions/hello.js imports volcano/_shared/utils.js as
./_shared/utils, and ../_shared/utils fails once deployed. Keep shared code
under volcano/functions/ instead, and a single-file function's ./_shared
import resolves in your repository too.
To make your tests resolve the same imports in your repository, link the shared directory into the function's directory:
ln -s ../_helpers volcano/functions/api/_helpersFor shared code directly under volcano/, link ../../_name instead. Node.js
and Ruby then resolve ./_helpers/db from volcano/functions/api/. Python
resolves _helpers.db once the function's directory is on sys.path, as it is
for a test file in that directory run with pytest or python. Deploys skip
symbolic links, so the function still gets the real _helpers/.
Dependencies
Cloud deployments upload source plus dependency manifests. Volcano installs dependencies during the function compile build using the selected runtime. Do not include installed dependency directories in cloud uploads.
Do not include the top-level .volcano-dependencies path. Volcano reserves it
for dependency staging, and the asynchronous compile build fails when uploaded
source contains it.
A common Node.js layout looks like:
package.json
volcano/
functions/
hello.js
api/
index.js
_shared/
utils.jsFor Python:
requirements.txt
volcano/
functions/
jobs.pyFor Ruby:
Gemfile
Gemfile.lock
volcano/
functions/
worker.rbLocal mode installs these manifests too, using the runtime's own package manager
(npm, pip, or bundler) on the interpreter the function's runtime names. You
do not need to install dependencies into your project first, and you should not
commit installed directories: node_modules, .venv, and vendor/bundle are
excluded from the upload either way.
Packaging
Functions can be uploaded to the API as ZIP or tar.gz source bundles. The Volcano CLI packages cloud functions as tar.gz. Include source files and dependency manifests/lockfiles, not installed dependency directories.
Uploaded function source archives cannot contain symlink entries. Package-manager symlinks created during the cloud build are materialized safely before publish.
Node.js
# Include source and dependency manifests
zip -r function.zip index.js package.jsonPython
zip -r function.zip main.py requirements.txtRuby
zip -r function.zip main.rb Gemfile Gemfile.lockNote: Upload focused source bundles. Include generated runtime output if your handler needs it, such as
dist/orbuild/, but exclude installed dependency directories, dependency caches, and files that are only useful during development.
Cloud uploads are limited to 256 MB per source archive, and that limit is enforced by the API only. The CLI does not reject function archives based on local file or archive size. After the cloud build installs dependencies, it rejects a final function image larger than 4096 MB.
functions deploy --all uploads functions in API batches of up to 100 functions. Larger projects are split into multiple batch requests automatically by the CLI.
volcano deploy (or volcano cloud deploy) deploys every function in volcano/functions/ along with the project's variables, durable functions, frontends, and volcano-config.yaml, then waits until the deployments are live. See the CLI deploy reference.
Deploying
Upload as binary file via multipart/form-data:
# Node.js function
curl -X POST https://api.volcano.dev/projects/PROJECT_ID/functions \
-H "Authorization: Bearer $PLATFORM_TOKEN" \
-F "name=my-node-function" \
-F "code=@function.zip" \
-F "runtime=nodejs24.x" \
-F "handler=handler"
# Python function (handler defaults to "handler" if not specified)
curl -X POST https://api.volcano.dev/projects/PROJECT_ID/functions \
-H "Authorization: Bearer $PLATFORM_TOKEN" \
-F "name=my-python-function" \
-F "code=@function.zip" \
-F "runtime=python3.12"
# Ruby function (handler defaults to "handler" if not specified)
curl -X POST https://api.volcano.dev/projects/PROJECT_ID/functions \
-H "Authorization: Bearer $PLATFORM_TOKEN" \
-F "name=my-ruby-function" \
-F "code=@function.zip" \
-F "runtime=ruby3.4"Parameters:
name(required) - Function name (unique per project, DNS-safe, max 63 characters) Allowed characters: lowercase letters, numbers, and hyphens (-), without leading/trailing hyphen.code(required) - ZIP ortar.gzfile containing the function source bundleruntime(required) - Runtime environment (see table above)handler(optional) - Entry point; defaults based on runtimevisibility(optional) -private,authenticated, orpublic; a new function defaults toprivate, and a redeploy without it keeps the current level. See Choose who can invoke it
Handler format:
You only need to specify the function name (e.g., handler). Volcano automatically packages your code with the standard filename for your language:
- Node.js:
exports.handlerinindex.js - Python:
def handler()inmain.py - Ruby:
def handler()inmain.rb
Response:
{
"id": "func-uuid",
"name": "my-function",
"status": "provisioning",
"runtime": "nodejs24.x",
"handler": "handler",
"visibility": "private",
"is_public": false,
"created_at": "2024-01-01T00:00:00Z"
}Status transitions: provisioning → active (usually 5-10 seconds).
Choose who can invoke it
A function's visibility decides which credentials can invoke it. New functions
are private.
| Visibility | Service keys and schedulers | Your project's signed-in users | Anon keys with functions.invoke |
|---|---|---|---|
private (default) | Yes | No | No |
authenticated | Yes | Yes | No |
public | Yes | Yes | Yes |
Anonymous sign-ins count as signed-in
users for authenticated. If your project allows them, anyone can get a token
that invokes an authenticated function. Check event.__volcano_auth.role
before acting for a registered account; see user context.
Set it in volcano-config.yaml and run volcano cloud config deploy:
# volcano-config.yaml
version: 1
functions:
- name: nightly-report
visibility: private
- name: notes-summary
visibility: authenticated
- name: contact-form
visibility: publicOr update one function through the API:
curl -X PATCH https://api.volcano.dev/projects/PROJECT_ID/functions/FUNCTION_ID \
-H "Authorization: Bearer $PLATFORM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"visibility":"authenticated"}'Or with the CLI:
volcano cloud functions update notes-summary --visibility authenticatedThe function does not run for a caller its level does not admit:
| Caller | Response |
|---|---|
Signed-in user, private function | 404 function not found |
Anon key, private function | 404 function not found |
Anon key, authenticated function, by id | 403 function is not public; anon keys can only invoke public functions |
Anon key, authenticated function, by name through an SDK | 404 function not found |
Anon key without functions.invoke, any function | 403 anon key does not have functions.invoke permission |
A private function answers exactly like one that doesn't exist, so signed-in
users and visitors can't discover its name or id. If your app gets 404 from a
function you deployed, check its visibility. The JavaScript, Python, and Ruby
SDKs report it as a not-found error, and they remember a name that wasn't found
for about 30 seconds, so a running client can keep getting not-found for that
long after you widen the level.
Treat a public function as internet-facing: anon keys ship in browser code.
A Frontend Function route can only target a
public function. While a route targets it, an update or redeploy that would
take the function out of public or HTTP mode is refused with 409, before
anything deploys.
is_public is a deprecated alias that requests and responses still carry:
true means public and false means authenticated, not private. Functions
created before visibility existed kept their behavior: is_public: false
became authenticated, and functions targeted by a Frontend Function route
became public. A manifest that still says public: false applies
authenticated on every deploy; see
project configuration.
Using environment variables
Access project variables in your function:
Node.js
exports.handler = async (event) => {
const apiKey = process.env.API_KEY;
const dbUrl = process.env.DATABASE_URL;
// Use them
};Python
import os
def handler(event, context):
api_key = os.environ.get('API_KEY')
db_url = os.environ.get('DATABASE_URL')
# Use themRuby
def handler(event:, context:)
api_key = ENV['API_KEY']
db_url = ENV['DATABASE_URL']
# Use them
endSee Environment Variables for details.
Using databases
Note:
DATABASE_URLcarriesapplication_name=volcano_full_access(admin access, bypasses row-level security) by default. The examples below use it as-is for simplicity. To scope a query to the invoking user under RLS, rewriteapplication_nametovolcano_user_access:{user_id}before connecting — see Direct connection.
Node.js
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
exports.handler = async (event) => {
const client = await pool.connect();
try {
const result = await client.query('SELECT * FROM posts');
return {
statusCode: 200,
body: JSON.stringify(result.rows),
};
} finally {
client.release();
}
};Python
import os
import psycopg2
import json
def handler(event, context):
conn = psycopg2.connect(os.environ['DATABASE_URL'])
cur = conn.cursor()
cur.execute('SELECT * FROM posts')
rows = cur.fetchall()
cur.close()
conn.close()
return {
'statusCode': 200,
'body': json.dumps(rows)
}Ruby
require 'pg'
require 'json'
def handler(event:, context:)
conn = PG.connect(ENV['DATABASE_URL'])
result = conn.exec('SELECT * FROM posts')
rows = result.map { |row| row }
conn.close
{
statusCode: 200,
body: rows.to_json
}
endManaging functions
List functions
curl https://api.volcano.dev/projects/PROJECT_ID/functions \
-H "Authorization: Bearer $PLATFORM_TOKEN"Get function details
curl https://api.volcano.dev/projects/PROJECT_ID/functions/FUNC_ID \
-H "Authorization: Bearer $PLATFORM_TOKEN"Delete a function
curl -X DELETE https://api.volcano.dev/projects/PROJECT_ID/functions/FUNC_ID \
-H "Authorization: Bearer $PLATFORM_TOKEN"This permanently removes the function.
What's next
| Guide | Description |
|---|---|
| Invoking functions | Call your deployed functions |
| Function logs | View function execution logs |
| Deploy from GitHub | Deploy volcano/functions/ automatically on every push |
| Examples | Complete working examples |