Fn Project is an open-source, container-native serverless platform. Every function is packaged as a Docker image, and the Fn server runs it in a container when it is called, keeping that container warm for a short time so the following calls are fast. In this tutorial you will install the Fn CLI and server on Ubuntu 24.04, run the server as a systemd service, deploy a Python function with an HTTP trigger, tune its resources, schedule it with a systemd timer and publish it through Nginx.

Prerequisites

To follow this tutorial, you need:

  • A server running Ubuntu 24.04 LTS on amd64, for example a CubePath VPS, with at least 2 vCPUs and 2 GB of RAM.
  • A non-root user with sudo privileges.
  • Docker Engine installed from Docker's official repository, with your user in the docker group so you can run docker without sudo.
  • For Step 7 only: a domain name with an A record pointing to your server, and Nginx installed with sudo apt install nginx.

Confirm that Docker works for your user:

docker run --rm hello-world

Step 1 - Installing the Fn CLI

The fn CLI creates function boilerplate, builds images, deploys them to the server and invokes them. It is a single binary published on the Fn CLI releases page. Download the Linux build of the current release, 0.6.69:

curl -fsSLo fn https://github.com/fnproject/cli/releases/download/0.6.69/fn_linux
sudo install -m 0755 fn /usr/local/bin/fn
rm fn

Check the installed version:

fn version
Client version is latest version: 0.6.69
Server version:  ?

The server version shows ? because the server is not running yet.

Step 2 - Running the Fn server as a systemd service

The Fn server is itself a container, fnproject/fnserver, that starts function containers through the Docker socket. The CLI includes fn start for quick local tests, but it runs in the foreground and publishes port 8080 on every interface. For a server that starts at boot and listens only on the loopback interface, create a systemd unit that runs the same container with the same options fn start uses.

Create a directory for the server database:

sudo mkdir -p /var/lib/fn

Create the unit file:

sudo nano /etc/systemd/system/fnserver.service
[Unit]
Description=Fn Project server
After=docker.service
Requires=docker.service

[Service]
Restart=always
RestartSec=5
ExecStartPre=-/usr/bin/docker rm -f fnserver
ExecStart=/usr/bin/docker run --rm --name fnserver \
  --privileged \
  -p 127.0.0.1:8080:8080 \
  -v fnserversocket:/iofs \
  -e FN_IOFS_DOCKER_PATH=fnserversocket \
  -e FN_IOFS_PATH=/iofs \
  -v /var/lib/fn:/app/data \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e FN_LOG_LEVEL=info \
  --entrypoint ./fnserver \
  fnproject/fnserver:latest
ExecStop=/usr/bin/docker stop fnserver

[Install]
WantedBy=multi-user.target

The options mean the following:

  • -p 127.0.0.1:8080:8080 publishes the API on the loopback interface only. The Fn API has no authentication, so it must not be reachable from the internet.
  • The fnserversocket volume and the two FN_IOFS_* variables are where the server and the function containers exchange requests over UNIX sockets.
  • /var/lib/fn keeps the SQLite database with your apps, functions and triggers across restarts.
  • --privileged and the Docker socket let the server manage function containers, exactly as fn start does. This gives the container root-level control of the host, which is one more reason to keep the API private.

Load the unit, then enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now fnserver

Check the service and the API:

systemctl status fnserver --no-pager
curl -s http://127.0.0.1:8080/version; echo
{"version":"0.3.770"}

Point the CLI's default context explicitly at the IPv4 loopback address and confirm it sees the server:

fn update context api-url http://127.0.0.1:8080
fn version
Client version is latest version: 0.6.69
Server version:  0.3.770

Step 3 - Creating and deploying your first function

Fn groups functions into applications. An application is a namespace that holds functions and shared configuration. Create one:

fn create app demo
fn list apps

Generate a Python function with an HTTP trigger. fn init creates a directory with the handler, its dependencies and a func.yaml file with the function metadata:

mkdir -p ~/fn && cd ~/fn
fn init --runtime python --trigger http hello
cd hello
ls
func.py  func.yaml  requirements.txt

The generated func.py reads a JSON body with an optional name and returns a greeting. Deploy it. The --local flag builds the image and registers the function on your server without pushing the image to a registry:

fn deploy --app demo --local

The first build pulls the Python build and runtime images, so it takes a minute or two. The output ends with lines similar to:

Successfully created function: hello with hello:0.0.2
Successfully created trigger: hello
Trigger Endpoint: http://127.0.0.1:8080/t/demo/hello

Invoke the function through the CLI, first without input and then with a JSON body:

fn invoke demo hello
echo -n '{"name": "Bob"}' | fn invoke demo hello --content-type application/json
{"message": "Hello World"}
{"message": "Hello Bob"}

The HTTP trigger exposes the same function at /t/<app>/<trigger path>:

curl -s -X POST http://127.0.0.1:8080/t/demo/hello \
  -H "Content-Type: application/json" \
  -d '{"name": "curl"}'
{"message": "Hello curl"}

Step 4 - Writing a function that uses configuration

Functions often need settings such as a greeting, a URL or a feature flag. Fn stores configuration on the application or the function and passes it to the function at runtime, so you can change it without rebuilding the image.

Replace the generated handler:

nano func.py
import io
import json
import logging

from fdk import response


def handler(ctx, data: io.BytesIO = None):
    name = "World"
    try:
        body = json.loads(data.getvalue())
        name = body.get("name", name)
    except (ValueError, AttributeError):
        pass

    greeting = ctx.Config().get("GREETING", "Hello")
    logging.getLogger().info("Greeting %s", name)

    return response.Response(
        ctx,
        response_data=json.dumps({"message": f"{greeting}, {name}!"}),
        headers={"Content-Type": "application/json"},
    )

Deploy the new version and set a configuration value on the function:

fn deploy --app demo --local
fn config function demo hello GREETING Hola

Call it again:

curl -s -X POST http://127.0.0.1:8080/t/demo/hello -d '{"name": "Alice"}'
{"message": "Hola, Alice!"}

List the configuration with fn list config function demo hello. Use fn config app demo KEY value for values that every function in the app should receive.

Step 5 - Tuning memory, timeouts and hot containers

After a call, Fn keeps the function container running for a period called the idle timeout, so the next calls skip the container start. These containers are known as hot functions. You can see the effect by timing two calls in a row:

time fn invoke demo hello
time fn invoke demo hello

The first call after a deploy or a long pause includes the container start; the second one returns in a few milliseconds.

The limits live in func.yaml. Open it:

nano func.yaml

Add memory, timeout and idle_timeout to the existing keys, leaving the generated schema_version, name, version, runtime, image and triggers entries as they are:

memory: 256
timeout: 30
idle_timeout: 60
  • memory is the container memory limit in MB (128 by default).
  • timeout is the maximum execution time of a call, in seconds.
  • idle_timeout is how many seconds a hot container waits for the next call before it is stopped.

Deploy again so the server picks up the new values, then inspect the function:

fn deploy --app demo --local
fn inspect function demo hello

The JSON output shows "memory": 256, "timeout": 30 and "idle_timeout": 60.

Step 6 - Running a function on a schedule

Fn has no built-in scheduler, but an HTTP trigger can be called by anything that runs on a timer. On Ubuntu, a systemd timer is the cleanest option: it logs every run to the journal and catches up after downtime.

Create the service that calls the trigger once:

sudo nano /etc/systemd/system/fn-hello.service
[Unit]
Description=Call the Fn function demo/hello
After=fnserver.service
Requires=fnserver.service

[Service]
Type=oneshot
ExecStart=/usr/bin/curl -fsS -X POST -H "Content-Type: application/json" -d "{\"name\": \"timer\"}" http://127.0.0.1:8080/t/demo/hello

Create the timer that runs it every five minutes:

sudo nano /etc/systemd/system/fn-hello.timer
[Unit]
Description=Run demo/hello every five minutes

[Timer]
OnCalendar=*:0/5
Persistent=true

[Install]
WantedBy=timers.target

Enable the timer and check the next run and the result of the first one:

sudo systemctl daemon-reload
sudo systemctl enable --now fn-hello.timer
systemctl list-timers fn-hello.timer --no-pager
sudo systemctl start fn-hello.service
journalctl -u fn-hello.service -n 5 --no-pager
Sep 25 10:40:01 fn01 curl[21544]: {"message": "Hola, timer!"}
Sep 25 10:40:01 fn01 systemd[1]: fn-hello.service: Deactivated successfully.

Step 7 - Publishing triggers with Nginx

To call functions from outside the server, publish only the trigger path /t/ through Nginx and keep the management API (/v2/) private. Create a server block, replacing your_domain with your domain:

sudo nano /etc/nginx/sites-available/fn
server {
    listen 80;
    listen [::]:80;
    server_name your_domain;

    client_max_body_size 5m;

    location /t/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }

    location / {
        return 404;
    }
}

Enable the site, test the configuration, reload Nginx and allow web traffic through UFW:

sudo ln -s /etc/nginx/sites-available/fn /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'

From your workstation, the trigger answers and the management API does not:

curl -s -X POST http://your_domain/t/demo/hello -d '{"name": "Internet"}'
curl -s -o /dev/null -w "%{http_code}\n" http://your_domain/v2/apps
{"message": "Hola, Internet!"}
404

Add HTTPS with Certbot (sudo apt install certbot python3-certbot-nginx, then sudo certbot --nginx -d your_domain) before sending real traffic. Anyone who knows a trigger URL can call it, so check an API key or token inside the function if the endpoint must not be public.

Troubleshooting

fn deploy fails with permission denied while trying to connect to the Docker daemon socket. Your user is not in the docker group, or the session started before you were added. Run sudo usermod -aG docker "$USER", then log out and back in. Do not change the permissions of /var/run/docker.sock.

The CLI cannot reach the server. Check the service and its logs, and confirm the CLI context points to http://127.0.0.1:8080:

systemctl status fnserver --no-pager
journalctl -u fnserver -n 50 --no-pager
fn list contexts

A call returns {"message":"Timed out"}. The function ran longer than its timeout. Raise the value in func.yaml and redeploy, or move slow work out of the request path.

The build fails. Run the deploy with verbose output to see the full Docker build log, including errors from requirements.txt:

fn --verbose deploy --app demo --local

Conclusion

You installed the Fn CLI and server on Ubuntu 24.04, ran the server as a systemd service bound to localhost, deployed a Python function with runtime configuration and an HTTP trigger, tuned its memory and hot container timeouts, scheduled it with a systemd timer, and published only the trigger endpoints through Nginx. As next steps, add functions in other runtimes such as Node.js or Go with fn init --runtime, push images to a private registry so several Fn servers can pull them, and secure the Nginx site with a Let's Encrypt certificate.