Templates
Deploy from a template, or write your own first-boot setup.
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.
#!/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:
| Variable | Value |
|---|---|
FC_PRIVATE_IPV4 | The machine's private IPv4 address |
FC_PUBLIC_IPV6 | The public IPv6 address (empty when the region has no v6 pool) |
FC_HOSTNAME | The guest hostname |
FC_UUID | The machine's internal VM id (ULID) |
FC_REGION | The region id the machine deploys to |
FC_WEB_ROUTES | All 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:
#!/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
fiThe 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:
#!/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_3000Guard 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:
(umask 077; printf 'TOKEN=%s\n' "$FC_TOKEN" > /etc/myapp/secret.env)
chown root:myapp /etc/myapp/secret.env
chmod 640 /etc/myapp/secret.envImages 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:
printf 'MY_APP_TOKEN=%s\n' "$FC_TOKEN" >> /etc/default/myappInstalling 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:
systemctl enable myapp
systemctl restart myappThe 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:
grafana cli admin reset-admin-password "$FC_ADMIN_PASSWORD" >/dev/nullSkip 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:
apt-get install -y --no-install-recommends docker.io docker-cliEvery 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:
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/fstabFirst 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:
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
doneProve 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:
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:
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.startPublishing & 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.appURL at deploy time with no manual step — this is how web UIs should ship (see networking). Your script can discover the minted URL through theFC_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.