Canadian KVM VPS in Montréal from 8.50 CAD/month

API

Recipes

Scripts that work: ephemeral runner, budget-driven fleet, shutdown on low runway.

Three scripts that run. They are short because the API already returns the numbers worth deciding on: balance, burn rate, runway, quota.

All of them assume FFXF_TOKEN in the environment and jq installed.

An ephemeral runner

The textbook case: a machine born for one job, destroyed when it ends. Real cost of an hour-long job on a Nano: 0.018 CAD.

bash
#!/usr/bin/env bash
set -euo pipefail
API=https://api.ffxf.net/v1
AUTH="Authorization: Bearer $FFXF_TOKEN"
NAME="runner-$(date +%s)"

# Order. The idempotency key protects against a double send on a network retry.
VM=$(curl -sf "$API/vms" -H "$AUTH" -H "Content-Type: application/json" \
     -H "Idempotency-Key: $NAME" \
     -d "{\"plan\":\"nano\",\"region\":\"montreal\",\"image\":\"debian-13\",
          \"hostname\":\"$NAME\",\"billing\":\"hourly\",\"password_delivery\":\"none\",
          \"ssh_keys\":[\"$FFXF_SSH_FINGERPRINT\"]}" | jq -r .data.vm.id)

# Wait for delivery: provisioning is asynchronous.
until [ "$(curl -sf "$API/vms/$VM" -H "$AUTH" | jq -r .data.status)" = "running" ]; do
  sleep 5
done
IP=$(curl -sf "$API/vms/$VM" -H "$AUTH" | jq -r .data.ipv4)

ssh -o StrictHostKeyChecking=accept-new "debian@$IP" "./my-job.sh"

# Destroy. The hostname confirms: without it, the API refuses.
curl -sf -X DELETE "$API/vms/$VM?confirm=$NAME" -H "$AUTH"

Note password_delivery: none: no machine is safer than one whose password is sitting in a mailbox. It requires at least one SSH key.

Shutting down before running dry

The API says how long the balance lasts at the current burn. A script reading that number never gets cut off by surprise.

bash
#!/usr/bin/env bash
# Run hourly. Under 12 hours of runway, shut the disposable machines down so
# what is left of the credit serves the ones that matter.
API=https://api.ffxf.net/v1
AUTH="Authorization: Bearer $FFXF_TOKEN"

RUNWAY=$(curl -sf "$API/account" -H "$AUTH" | jq -r '.data.hourly.runway_hours // 9999')

if (( $(echo "$RUNWAY < 12" | bc -l) )); then
  curl -sf "$API/vms" -H "$AUTH" \
    | jq -r '.data[] | select(.hostname | startswith("runner-")) | .id' \
    | while read -r vm; do
        curl -sf -X POST "$API/vms/$vm/actions" -H "$AUTH" \
             -H "Content-Type: application/json" -d '{"type":"shutdown"}'
      done
fi

A powered-off machine is still billed: the hour runs as long as the machine exists, on or off. To stop the meter you must destroy it. This script buys time, it saves nothing — swap shutdown for deletion if saving is what you are after.

An inventory that costs what it says

The per-machine ledger answers the only question that matters at month end: which one is expensive.

bash
curl -sf "https://api.ffxf.net/v1/usage?group_by=vm" -H "Authorization: Bearer $FFXF_TOKEN" \
  | jq -r '.data[] | [.hostname // "(destroyed)", .hours, .amount] | @tsv' \
  | sort -k3 -rn

Destroyed machines appear with their name: what they cost does not vanish with them.

Handling refusals properly

A script that reads the code rather than the message survives rewordings and translations.

bash
response=$(curl -s -w '\n%{http_code}' "$API/vms" -H "$AUTH" \
           -H "Idempotency-Key: $NAME" -H "Content-Type: application/json" -d "$BODY")
body=$(echo "$response" | head -n -1); code=$(echo "$response" | tail -n1)

case "$(echo "$body" | jq -r '.error.code // "ok"')" in
  ok)                  echo "ordered" ;;
  insufficient_credit) echo "top up $(echo "$body" | jq -r .error.details.missing) CAD" ;;
  quota_reached)       echo "cap reached: destroy one, or ask support" ;;
  rate_limited)        sleep 60; exec "$0" "$@" ;;
  *)                   echo "refused: $body"; exit 1 ;;
esac

Support & discussions

Technical questions, incident reports, or infrastructure discussions, the team is reachable on Discord, Telegram, X, and IRC.