Skip to content
Farlec docs

Templates

Deploy from a template, or write your own first-boot setup.

7 min read Updated

What templates are#

A template is a shell postscript plus declared variables, one pinned OS and resource floors. Machines deployed from it run the script once on first boot — always on the template's OS — and snapshot the template's id and name at deploy time, so later edits never touch existing machines.

Every template carries a unique 5-digit code (for example 04217). It is system-generated, never editable, and never reused — even after the template is deleted. Use it to search on the templates page, to find a template in the deploy wizard's table, or to deploy it directly: /app/machines/deploy?template=04217 accepts the code (the template id still works too), and POST /api/v1/machines template_id takes either.

Templates are freely editable and hard-deletable, with limits:

  • Body ≤ 20 KiB, ≤ 16 variables, short description ≤ 200 chars, description ≤ 5,000 chars, 10 templates per account.
  • Syntax is checked (sh -n) at save time.
  • The rendered script must stay under the guest's 24 KiB mailbox.

Variables#

Variables are typed (text, password, number, select) and injected as environment variables prefixed FC_ — reference env vars in the script body, never splice values into text. Password-type values ride only the deploy call: never stored, never audited, gone after create.

bash
#!/bin/sh
# variables: FC_DB_PASS (password), FC_PORT (number)
htpasswd -bc /etc/app/htpasswd admin "$FC_DB_PASS"
listen_port="${FC_PORT:-3000}"
echo "app listening on $listen_port"

Fixed variables#

The following variables are platform-guaranteed on every deploy — you never declare them, and template variables cannot use their names. Values resolve on the machine itself, the moment before your script's first line runs:

VariableValue
FC_PRIVATE_IPV4The machine's private IPv4 address
FC_PUBLIC_IPV6The public IPv6 address (empty when the region has no v6 pool)
FC_HOSTNAMEThe guest hostname
FC_UUIDThe machine's internal VM id (ULID)
FC_REGIONThe region id the machine deploys to
FC_WEB_ROUTESAll the machine's public web-route hosts, space-separated in port order (empty when it has none)
FC_ROUTE_<PORT>The public web-route host for that exact guest port — e.g. FC_ROUTE_3000 — set only for ports a minted route serves

This is how a template reaches values that only exist at deploy time — a database that must listen on its private address, for example:

bash
#!/bin/sh
apk add postgresql
if [ -n "$FC_PRIVATE_IPV4" ]; then
	sed -i "s/^#listen_addresses.*/listen_addresses = '$FC_PRIVATE_IPV4'/" /var/lib/postgresql/postgresql.conf
fi

The web-route pair is the same idea for a template that declares Web (https) ports: each one mints a public https://<route-id>.farlec.app URL at deploy, and the script can wire that URL into whatever it installs — the incoming domain of a web server, or an app's own public base URL:

bash
#!/bin/sh
# template declares web port 3000 → https://<route-id>.farlec.app proxies to :3000
apk add nginx
if [ -n "$FC_ROUTE_3000" ]; then
	sed -i "s/^#server_name.*/server_name $FC_ROUTE_3000;/" /etc/nginx/http.d/default.conf
fi
mkdir -p /run/nginx
nginx
# app base URL: https://$FC_ROUTE_3000

Guard with -n when the script cannot proceed without a value, and exit non-zero — a failed script surfaces in the machine's setup status, while the machine itself is unaffected. The web-route variables resolve empty on machines created from older images (before train 20261026-2) — the guard covers those too.

Writing robust scripts#

Patterns from real template deploys.

Your script runs with umask 022. Files it creates are 0644 and directories 0755, so a non-root service can read them. Anything holding a secret therefore needs its own mode, set before the secret is written:

bash
(umask 077; printf 'TOKEN=%s\n' "$FC_TOKEN" > /etc/myapp/secret.env)
chown root:myapp /etc/myapp/secret.env
chmod 640 /etc/myapp/secret.env

Images built before 2026-11-08 ran scripts at umask 077. A umask 022 line at the top of the script is harmless and covers them.

Append to packaged config files, never overwrite them. Debian packages ship /etc/default/<service> files whose variables the systemd unit expands (${DATA_DIR}, ${CONF_FILE}, ...). Overwriting the file empties those and the service comes up with broken paths. Append instead:

bash
printf 'MY_APP_TOKEN=%s\n' "$FC_TOKEN" >> /etc/default/myapp

Installing a package starts it. On Farlec's Debian and Ubuntu images, apt-get install brings the service up immediately, before your next line runs. Config written after the install has not been read by the running process, and systemctl enable --now is a no-op on an already-running unit — enable, then restart:

bash
systemctl enable myapp
systemctl restart myapp

The same race breaks first-run seeding. A password set through an env var only applies when the account is first created, and the package's auto-start may have created it with defaults already. Force the value afterwards with a tool that works on existing state — and silence commands that echo secrets, because the script's output tail is stored and shown in the machine's setup panel:

bash
grafana cli admin reset-admin-password "$FC_ADMIN_PASSWORD" >/dev/null

Skip prerequisite packages the image already carries. Every image ships curl and ca-certificates, and apt accepts armored GPG keys in signed-by= directly — installing wget, gnupg or apt-transport-https costs an extra apt-get update cycle for nothing. Add the repo first and a single apt-get update covers indices and dependencies together. Two more apt habits: -o Acquire::Retries=5 rides out transient mirror errors, and DEBIAN_FRONTEND=noninteractive stops dpkg from waiting on a prompt nobody can answer inside a postscript.

apt-get install brings Recommends. On Debian and Ubuntu images, apt installs recommended packages as a stock distro does, so an install can pull in extra packages and sometimes extra services. Suggested packages stay off. For a smaller, faster install, pass --no-install-recommends and name every package you need:

bash
apt-get install -y --no-install-recommends docker.io docker-cli

Every machine already has 256 MiB of swap. It is active at /swapfile before your script's first line runs, on every image and plan, so a check for "is there any swap?" always passes. If an install needs more, add a second file at another path:

bash
dd if=/dev/zero of=/swapfile.app bs=1M count=2048 status=none
chmod 600 /swapfile.app
mkswap /swapfile.app >/dev/null
swapon /swapfile.app
echo '/swapfile.app none swap sw 0 0' >> /etc/fstab

First boot, first DNS. The network is up before your script runs, but resolution can lag a few seconds. Probe for reachability instead of failing on the first miss:

bash
i=0
until curl -fsS -o /dev/null --max-time 10 https://apt.example.com; do
	i=$((i + 1))
	[ "$i" -ge 12 ] && { echo "network never came up" >&2; exit 1; }
	sleep 5
done

Prove the service answers, then exit zero. A local health check turns "package installed" into "service works". A non-zero exit surfaces in the machine's setup status while the machine itself is unaffected — and a dead unit should bail early with its journal, which lands in the same output:

bash
i=0
while [ "$i" -lt 60 ]; do
	curl -fsS -o /dev/null http://127.0.0.1:8080/health && break
	if systemctl is-failed myapp >/dev/null 2>&1; then
		journalctl -u myapp -n 20 --no-pager >&2
		exit 1
	fi
	i=$((i + 1))
	sleep 2
done
[ "$i" -lt 60 ] || { echo "service never answered" >&2; exit 1; }

Reboots are the script's job to prepare for. The script runs once; the machine reboots forever. systemctl enable is what makes the service survive them.

Alpine images have no service manager: no systemctl, no rc-service, and installing a package does not start it. Start the daemon from your script, and add an executable /etc/local.d/*.start file to start it on every later boot:

bash
mkdir -p /etc/local.d
cat > /etc/local.d/nginx.start <<'EOF'
#!/bin/sh
mkdir -p /run/nginx
exec nginx
EOF
chmod 755 /etc/local.d/nginx.start

Publishing & official templates#

New templates are private. Publishing asks for review; approved templates turn public for everyone, rejected ones return private with a reason. You can unpublish again at any time. Farlec also maintains official templates, pinned above the community list.

Public ports#

A template can declare up to 16 ports in the editor, of two kinds:

  • TCP/UDP rows open raw forwards on the node's shared public IPv4 automatically — the public host port is allocated for you, and the machine drawer's Network tab lists the mappings (see machines). Use them for non-web traffic: game servers, WireGuard, databases.
  • Web (https) rows publish the port as a public https://<route-id>.farlec.app URL at deploy time with no manual step — this is how web UIs should ship (see networking). Your script can discover the minted URL through the FC_WEB_ROUTES / FC_ROUTE_<PORT> fixed variables — see fixed variables.

Ports are applied when the machine is created; reinstalls keep the machine's existing forwards and routes.

Deploying from a template#

On the templates page, press Deploy on a card or in the drawer: a dialog asks for a plan and a region, then the deploy wizard opens directly on Name & Access with those picks carried in — you can still step back to change either. In the deploy wizard's Template step, the searchable table lists every template you can deploy: official, public, and your own (private included). The template locks the OS to its pinned image, the plan respects its resource floors, and the variable form appears (password variables get a generate button). The free tier can use templates whose pinned OS is Alpine and whose floors fit — the free plan deploys Alpine only.