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

LanguageRuntimesDefault handler
Node.jsnodejs24.x, nodejs22.xhandler
Pythonpython3.14, python3.13, python3.12, python3.11, python3.10handler
Rubyruby4.0, ruby3.4, ruby3.3handler

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, or index.cts. 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 --noEmit in your own CI to catch type errors.
  • It bundles the files the handler imports, including imports through paths in a tsconfig.json deployed with the function. Packages from package.json aren't bundled; they load from the dependencies the build installs.
  • .mts compiles to an ES module and .cts to CommonJS. .ts follows Node.js: an ES module when the nearest package.json, looking up to the one the function's dependencies install from, sets "type": "module", CommonJS otherwise. Top-level await and import.meta need an ES module.
  • 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, or index.cjs file, an extensionless index file, an index/ directory holding a package.json or an index.js, index.json, or index.node, an installed package named index, or an index module in a NODE_PATH directory inside the function. Any package.json counts, whatever its main names. An empty index/ directory, or one with none of these, doesn't count, and neither does a NODE_PATH directory outside the function. Commit compiled JavaScript when you need a compiler feature the build skips, such as decorator metadata.
  • Git deploy reads only index.js or index.mjs as a JavaScript entry beside index.ts. A shipped index.cjs, extensionless index file or index/ directory that counts beside it deploys neither, and the push fails with typescript_not_compiled.
  • Keep one of index.ts, index.mts and index.cts. A function with more than one fails to deploy.
  • TypeScript never changes what a function directory already deploys in Git deploy: a JavaScript file, main.py or main.rb beside index.ts stays the entry, with its runtime.
  • .tsx files aren't compiled.

Git deploy and the upload API compile TypeScript, in the cloud and in local mode. The CLI doesn't find .ts files yet, so compile a function to JavaScript before you deploy it with the CLI.

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
  }
end

CLI 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/notes.py
volcano/functions/worker.rb

Examples:

  • volcano/functions/hello.js -> function name hello
  • volcano/functions/notes.py -> function name notes
  • volcano/functions/worker.rb -> function name worker

Directory-based functions

Use a directory when a function needs multiple files.

volcano/functions/api/index.js
volcano/functions/jobs/main.py
volcano/functions/reports/main.rb

The directory name becomes the function name.

Discovery rules:

  • The CLI first looks for index.* inside the directory.
  • If no index.* file exists, it falls back to the first supported runtime file it finds.

Examples:

  • volcano/functions/api/index.js -> function name api
  • volcano/functions/jobs/main.py -> function name jobs

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.

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

For Python:

requirements.txt
volcano/
  functions/
    jobs.py

For Ruby:

Gemfile
Gemfile.lock
volcano/
  functions/
    worker.rb

Local 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.json

Python

zip -r function.zip main.py requirements.txt

Ruby

zip -r function.zip main.rb Gemfile Gemfile.lock

Note: Upload focused source bundles. Include generated runtime output if your handler needs it, such as dist/ or build/, 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 or tar.gz file containing the function source bundle
  • runtime (required) - Runtime environment (see table above)
  • handler (optional) - Entry point; defaults based on runtime
  • visibility (optional) - private, authenticated, or public; a new function defaults to private, 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.handler in index.js
  • Python: def handler() in main.py
  • Ruby: def handler() in main.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.

VisibilityService keys and schedulersYour project's signed-in usersAnon keys with functions.invoke
private (default)YesNoNo
authenticatedYesYesNo
publicYesYesYes

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: public

Or 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 authenticated

The function does not run for a caller its level does not admit:

CallerResponse
Signed-in user, private function404 function not found
Anon key, private function404 function not found
Anon key, authenticated function, by id403 function is not public; anon keys can only invoke public functions
Anon key, authenticated function, by name through an SDK404 function not found
Anon key without functions.invoke, any function403 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 them

Ruby

def handler(event:, context:)
  api_key = ENV['API_KEY']
  db_url = ENV['DATABASE_URL']
  # Use them
end

See Environment Variables for details.

Using databases

Note: DATABASE_URL carries application_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, rewrite application_name to volcano_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
  }
end

Managing 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

GuideDescription
Invoking functionsCall your deployed functions
Function logsView function execution logs
Deploy from GitHubDeploy volcano/functions/ automatically on every push
ExamplesComplete working examples

On this page