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
sudoprivileges. - Docker Engine installed from Docker's official repository, with your user in the
dockergroup so you can rundockerwithoutsudo. - For Step 7 only: a domain name with an
Arecord pointing to your server, and Nginx installed withsudo 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:8080publishes the API on the loopback interface only. The Fn API has no authentication, so it must not be reachable from the internet.- The
fnserversocketvolume and the twoFN_IOFS_*variables are where the server and the function containers exchange requests over UNIX sockets. /var/lib/fnkeeps the SQLite database with your apps, functions and triggers across restarts.--privilegedand the Docker socket let the server manage function containers, exactly asfn startdoes. 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
memoryis the container memory limit in MB (128 by default).timeoutis the maximum execution time of a call, in seconds.idle_timeoutis 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.
