Building Erlang Apps

This guide shows how to build Erlang/OTP applications that embed Hornbeam as a component. You’ll learn to create production-ready releases with supervision trees, custom handlers, and proper configuration.

Project Setup

Create New Project

rebar3 new app my_app
cd my_app

Add Dependencies

%% rebar.config
{deps, [
    {hornbeam, {git, "https://github.com/benoitc/hornbeam.git", {branch, "main"}}}
]}.

{shell, [
    {apps, [my_app]}
]}.

{relx, [
    {release, {my_app, "1.0.0"}, [
        my_app,
        hornbeam,
        sasl
    ]},
    {mode, dev},
    {extended_start_script, true},
    {sys_config, "config/sys.config"},
    {vm_args, "config/vm.args"}
]}.

{profiles, [
    {test, [
        {deps, [
            {hackney, "3.0.2"}
        ]}
    ]}
]}.

Project Structure

my_app/
├── rebar.config
├── config/
│   ├── sys.config          # Application configuration
│   └── vm.args              # Erlang VM arguments
├── src/
│   ├── my_app.app.src       # Application resource file
│   ├── my_app_app.erl       # Application behaviour
│   ├── my_app_sup.erl       # Root supervisor
│   ├── my_app_web.erl       # Hornbeam integration
│   └── my_app_handlers.erl  # Custom Erlang handlers
└── priv/
    └── python/
        ├── app.py           # Python WSGI/ASGI app
        └── requirements.txt

Application Module

app.src

%% src/my_app.app.src
{application, my_app, [
    {description, "My Hornbeam Application"},
    {vsn, "1.0.0"},
    {registered, []},
    {mod, {my_app_app, []}},
    {applications, [
        kernel,
        stdlib,
        hornbeam
    ]},
    {env, [
        {http_port, 8000},
        {python_app, "app:application"},
        {worker_class, wsgi}
    ]},
    {modules, []},
    {licenses, ["Apache-2.0"]},
    {links, []}
]}.

Application Behaviour

%% src/my_app_app.erl
-module(my_app_app).
-behaviour(application).

-export([start/2, stop/1]).

start(_StartType, _StartArgs) ->
    my_app_sup:start_link().

stop(_State) ->
    ok.

Supervisor Tree

Root Supervisor

%% src/my_app_sup.erl
-module(my_app_sup).
-behaviour(supervisor).

-export([start_link/0, init/1]).

start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

init([]) ->
    SupFlags = #{
        strategy => one_for_one,
        intensity => 10,
        period => 60
    },
    Children = [
        %% Start Hornbeam web server
        #{
            id => my_app_web,
            start => {my_app_web, start_link, []},
            restart => permanent,
            type => worker
        }
    ],
    {ok, {SupFlags, Children}}.

Web Server Worker

%% src/my_app_web.erl
-module(my_app_web).
-behaviour(gen_server).

-export([start_link/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

init([]) ->
    %% Register Erlang functions callable from Python
    register_functions(),

    %% Get configuration
    {ok, App} = application:get_env(my_app, python_app),
    {ok, Port} = application:get_env(my_app, http_port),
    {ok, WorkerClass} = application:get_env(my_app, worker_class),

    %% Start Hornbeam
    case hornbeam:start(App, #{
        bind => "0.0.0.0:" ++ integer_to_list(Port),
        worker_class => WorkerClass,
        pythonpath => [code:priv_dir(my_app) ++ "/python"]
    }) of
        ok ->
            logger:info("Hornbeam started on port ~p", [Port]),
            {ok, #{port => Port}};
        {error, Reason} ->
            {stop, Reason}
    end.

handle_call(_Request, _From, State) ->
    {reply, ok, State}.

handle_cast(_Msg, State) ->
    {noreply, State}.

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    hornbeam:stop(),
    ok.

%% Internal functions

register_functions() ->
    %% Register functions Python can call
    hornbeam:register_function(get_config, fun get_config/1),
    hornbeam:register_function(validate_request, fun validate_request/1),
    hornbeam:register_function(process_data, my_app_handlers, process_data),
    ok.

get_config([Key]) when is_binary(Key) ->
    case application:get_env(my_app, binary_to_atom(Key, utf8)) of
        {ok, Value} -> Value;
        undefined -> null
    end.

validate_request([Data]) when is_map(Data) ->
    %% Custom validation logic
    case maps:get(<<"id">>, Data, undefined) of
        undefined -> #{valid => false, error => <<"id required">>};
        _ -> #{valid => true}
    end.

Custom Handlers

Erlang Handlers

%% src/my_app_handlers.erl
-module(my_app_handlers).
-export([process_data/1, handle_webhook/1]).

%% Called from Python via hornbeam:register_function
process_data([Data]) ->
    %% Process data using Erlang's concurrency
    Results = parallel_process(Data),
    #{status => ok, results => Results}.

handle_webhook([Payload]) ->
    %% Handle webhook asynchronously
    spawn(fun() ->
        process_webhook(Payload)
    end),
    #{accepted => true}.

%% Internal

parallel_process(Items) when is_list(Items) ->
    Parent = self(),
    Refs = [spawn_monitor(fun() ->
        Parent ! {self(), process_item(Item)}
    end) || Item <- Items],
    [receive
        {Pid, Result} -> Result
    after 5000 ->
        {error, timeout}
    end || {Pid, _Ref} <- Refs].

process_item(Item) ->
    %% Your processing logic
    Item.

process_webhook(Payload) ->
    %% Async webhook processing
    logger:info("Processing webhook: ~p", [Payload]).

Hooks for Bidirectional Calls

%% Register hooks for Python to call
init_hooks() ->
    %% Erlang handler for embeddings
    hornbeam_hooks:reg(<<"embeddings">>, fun(Action, Args, _Kwargs) ->
        case Action of
            <<"encode">> ->
                [Text] = Args,
                %% Call Python ML model via py module
                py:call(embedding_model, encode, [Text]);
            <<"similarity">> ->
                [A, B] = Args,
                py:call(embedding_model, similarity, [A, B])
        end
    end),

    %% Erlang handler for auth
    hornbeam_hooks:reg(<<"auth">>, my_app_auth, handle, 3),

    ok.

Configuration

sys.config

%% config/sys.config
[
    {my_app, [
        {http_port, 8000},
        {python_app, "app:application"},
        {worker_class, asgi},
        {api_key, "${API_KEY}"},
        {db_pool_size, 10}
    ]},
    {hornbeam, [
        {workers, 4},
        {timeout, 30000},
        {max_requests, 10000}
    ]},
    {kernel, [
        {logger_level, info},
        {logger, [
            {handler, default, logger_std_h, #{
                formatter => {logger_formatter, #{
                    template => [time, " ", level, " ", msg, "\n"]
                }}
            }}
        ]}
    ]}
].

vm.args

## config/vm.args
-name my_app@127.0.0.1
-setcookie mysecretcookie

+K true
+A 128
+SDio 128

-env ERL_MAX_PORTS 65536
-env ERL_FULLSWEEP_AFTER 10

Python Integration

WSGI App

# priv/python/app.py
from hornbeam_erlang import call, state_get, state_set, state_incr

def application(environ, start_response):
    path = environ.get('PATH_INFO', '/')

    if path == '/':
        return handle_index(environ, start_response)
    elif path == '/api/process':
        return handle_process(environ, start_response)
    else:
        return handle_404(environ, start_response)

def handle_index(environ, start_response):
    # Get config from Erlang
    config = call('get_config', 'app_name')

    # Track request count
    count = state_incr('requests:total')

    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [f'Welcome to {config}! Request #{count}'.encode()]

def handle_process(environ, start_response):
    import json

    # Read request body
    content_length = int(environ.get('CONTENT_LENGTH', 0))
    body = environ['wsgi.input'].read(content_length)
    data = json.loads(body)

    # Validate via Erlang
    validation = call('validate_request', data)
    if not validation.get('valid'):
        start_response('400 Bad Request', [('Content-Type', 'application/json')])
        return [json.dumps({'error': validation.get('error')}).encode()]

    # Process via Erlang (uses Erlang's parallel processing)
    result = call('process_data', data)

    start_response('200 OK', [('Content-Type', 'application/json')])
    return [json.dumps(result).encode()]

def handle_404(environ, start_response):
    start_response('404 Not Found', [('Content-Type', 'text/plain')])
    return [b'Not Found']

ASGI App (FastAPI)

# priv/python/app.py
from fastapi import FastAPI, HTTPException
from contextlib import asynccontextmanager
from hornbeam_erlang import call, state_incr, register_hook

@asynccontextmanager
async def lifespan(app):
    # Startup: register Python services
    register_hook('ml_service', MLService())
    yield
    # Shutdown: cleanup

app = FastAPI(lifespan=lifespan)

class MLService:
    def __init__(self):
        from sentence_transformers import SentenceTransformer
        self.model = SentenceTransformer('all-MiniLM-L6-v2')

    def encode(self, text):
        return self.model.encode(text).tolist()

@app.get("/")
async def root():
    count = state_incr('requests:total')
    return {"message": "Hello", "request_number": count}

@app.post("/api/process")
async def process(data: dict):
    # Validate via Erlang
    validation = call('validate_request', data)
    if not validation.get('valid'):
        raise HTTPException(400, validation.get('error'))

    # Process via Erlang
    result = call('process_data', data)
    return result

Building Releases

Development

# Compile and run shell
rebar3 shell

# Or run specific commands
rebar3 compile
erl -pa _build/default/lib/*/ebin -eval "application:ensure_all_started(my_app)"

Production Release

# Build release
rebar3 as prod release

# Run
_build/prod/rel/my_app/bin/my_app foreground

# Or as daemon
_build/prod/rel/my_app/bin/my_app daemon
_build/prod/rel/my_app/bin/my_app stop

Docker

FROM erlang:27 AS builder
WORKDIR /app
COPY rebar.config rebar.lock ./
RUN rebar3 compile
COPY . .
RUN rebar3 as prod release

FROM python:3.13-slim
RUN apt-get update && apt-get install -y libncurses5 libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/_build/prod/rel/my_app /app
COPY priv/python/requirements.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/requirements.txt
WORKDIR /app
EXPOSE 8000
CMD ["bin/my_app", "foreground"]

Clustering

Connecting Nodes

%% In vm.args, use -name for distributed mode
%% -name my_app@192.168.1.10

%% Connect nodes programmatically
connect_to_cluster() ->
    Nodes = ['my_app@192.168.1.11', 'my_app@192.168.1.12'],
    [net_adm:ping(N) || N <- Nodes].

Distributed State

%% State is automatically shared via hornbeam_state (ETS)
%% For cross-node state, use hornbeam_dist

%% From Python
from hornbeam_erlang import rpc_call, nodes

def get_from_remote(key):
    for node in nodes():
        result = rpc_call(node, 'hornbeam_state', 'get', [key], 5000)
        if result is not None:
            return result
    return None

Testing

Common Test

%% test/my_app_SUITE.erl
-module(my_app_SUITE).
-include_lib("common_test/include/ct.hrl").

-export([all/0, init_per_suite/1, end_per_suite/1]).
-export([test_api/1]).

all() -> [test_api].

init_per_suite(Config) ->
    {ok, _} = application:ensure_all_started(hackney),
    {ok, _} = application:ensure_all_started(my_app),
    Config.

end_per_suite(_Config) ->
    application:stop(my_app),
    application:stop(hackney),
    ok.

test_api(_Config) ->
    {ok, 200, _Headers, ClientRef} = hackney:request(get, <<"http://localhost:8000/">>, [], <<>>, []),
    {ok, Body} = hackney:body(ClientRef),
    true = is_binary(Body),
    ok.

API Reference

For complete Erlang API documentation, see:

See Also