All of these assume L1_API_KEY is exported and jq is installed.
export L1_API_KEY="l1_..."
export L1="https://layeronecloud.com/api/v1"
auth=(-H "Authorization: Bearer $L1_API_KEY")
A one-line fleet listing
curl -sS "$L1/servers" "${auth[@]}" \
| jq -r '.servers[] | [.id, .label, .status, (.ipv4_address // "-"), .plan] | @tsv' \
| column -t
Cheapest plan that fits a requirement
curl -sS "$L1/plans" "${auth[@]}" | jq -r '
.plans
| map(select(.specs.memory_mb >= 4096))
| sort_by(.pricing.monthly | tonumber)
| .[0] | "\(.slug) $\(.pricing.monthly)/mo \(.specs.cpu_cores) vCPU \(.specs.memory_mb)MB"'
Deploy and wait for the address
#!/usr/bin/env bash
set -euo pipefail
created=$(curl -sS -X POST "$L1/servers" \
-H "Authorization: Bearer $L1_API_KEY" \
-H "Content-Type: application/json" \
-d '{"plan":"layerone-starter","image":"ubuntu-24-04","label":"web-01"}')
id=$(echo "$created" | jq -r '.server.id')
echo "$created" | jq -r '.root_password' > "./root-password-$id"
chmod 600 "./root-password-$id"
echo "server $id queued; password saved to ./root-password-$id"
for _ in $(seq 1 60); do
server=$(curl -sS "$L1/servers/$id" "${auth[@]}" | jq -r '.server')
status=$(echo "$server" | jq -r '.status')
address=$(echo "$server" | jq -r '.ipv4_address // ""')
if [ "$status" = "running" ] && [ -n "$address" ]; then
echo "ready: $address"
exit 0
fi
echo " $status ..."
sleep 10
done
echo "still not running after 10 minutes; check the portal" >&2
exit 1
The password is written to a file rather than echoed, because a terminal scrollback is not a secret store. Move it into your password manager and delete the file.
Lock the firewall down to your own address
me=$(curl -sS https://api.ipify.org)
curl -sS -X POST "$L1/servers/123/firewall/rules" \
-H "Authorization: Bearer $L1_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"direction\": \"in\", \"action\": \"ACCEPT\", \"protocol\": \"tcp\", \"port\": \"22\", \"source\": \"$me/32\", \"comment\": \"admin ssh\"}"
curl -sS -X PUT "$L1/servers/123/firewall" \
-H "Authorization: Bearer $L1_API_KEY" \
-H "Content-Type: application/json" \
-d '{"inbound_policy": "DROP", "outbound_policy": "ACCEPT"}'
direction is in or out, in lower case. action, inbound_policy and
outbound_policy are ACCEPT or DROP, in upper case. Neither is
normalised for you, so the wrong case is a 400. protocol is tcp, udp,
icmp, or omitted for any. A server holds at most 40 rules.
Order matters
Add the allow rule before setting the inbound policy to drop, or you
lock yourself out. If the policy change would leave no way in, the API
refuses it; passing "confirm_ssh_lockout": true overrides that refusal, so
only send it when you are certain you have another route in, such as the
browser console.
Read the firewall back
curl -sS "$L1/servers/123/firewall" "${auth[@]}" | jq '.firewall | {
inbound_policy, outbound_policy, inbound_ssh_blocked,
rules: [.rules[] | "\(.direction) \(.action) \(.protocol) \(.port // "any") from \(.source // "any")"]
}'
inbound_ssh_blocked is the field to check before you disconnect.
Which server is using the transfer pool
Transfer is pooled per account, not capped per server. GET /api/v1/account
returns this month's pool and each server's share in one request. Use that for
fleet monitoring. The per-server endpoint adds previous months and inbound
versus outbound. Each billable server adds 500 GB
(extra_per_additional_server_gb) to the same pool.
curl -sS "$L1/account" "${auth[@]}" | jq '.account.bandwidth | {
used_tb, allowance_tb, remaining_tb, extra_per_additional_server_gb, is_over,
servers: [.servers[] | {id, label, used_tb, inbound_bytes, outbound_bytes}]
}'
curl -sS "$L1/servers/123/bandwidth" "${auth[@]}" | jq '.bandwidth | {
current, account_pool: .account_pool | {used_tb, allowance_tb, remaining_tb}
}'
specs.bandwidth_tb on a server object is the plan's published figure, not this
month's usage. Inbound and outbound are the guest's totals across every
interface, public and private, so VNet traffic counts. A server that has not
been sampled yet still returns zeros for the current month rather than 404.
See Bandwidth: pool, blocks, and overage.
Power actions
action is one of start, stop, restart, shutdown.
curl -sS -X POST "$L1/servers/123/actions" \
-H "Authorization: Bearer $L1_API_KEY" \
-H "Content-Type: application/json" \
-d '{"action": "restart"}'
shutdown asks the operating system to shut down cleanly (ACPI halt). stop cuts the power.
Prefer shutdown unless the server is unresponsive. There is no pause: a QEMU RAM freeze
does not survive a node reboot, and billing does not stop while a server is halted. See
Power actions.
Private networks
Create a VNet, then attach servers. Caps and CIDR rules are the same as the portal
(ten networks per account by default, RFC1918 /29 to /24). A server may join more
than one network. The server object lists every attachment as private_networks[].
curl -sS -X POST "$L1/networks" \
-H "Authorization: Bearer $L1_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "office", "cidr": "10.20.0.0/24"}'
curl -sS -X POST "$L1/servers/123/networks" \
-H "Authorization: Bearer $L1_API_KEY" \
-H "Content-Type: application/json" \
-d '{"network": 12, "address": "10.20.0.10"}'
PATCH /networks/<id> accepts name, cidr, gateway (empty or null clears it), and
cloud_init_assign_ips. POST /networks/<id>/sync retries applying the saved network.
DELETE /servers/<id>/networks/<network_id> detaches. See
Private networks.
Check your quota before a batch job
curl -sS "$L1/account" "${auth[@]}" | jq '.account.api_usage'
Python, with the errors handled
import os
import time
import requests
BASE = "https://layeronecloud.com/api/v1"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['L1_API_KEY']}"
class LayerOneError(RuntimeError):
def __init__(self, code: str, message: str):
super().__init__(f"{code}: {message}")
self.code = code
def call(method: str, path: str, **kwargs):
for attempt in range(5):
response = SESSION.request(method, f"{BASE}{path}", timeout=30, **kwargs)
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", 60)))
continue
payload = response.json()
if response.ok:
return payload
error = payload.get("error", {})
code = error.get("code", "server_error")
if code == "server_error" and attempt < 4:
time.sleep(2 ** attempt)
continue
raise LayerOneError(code, error.get("message", ""))
raise LayerOneError("rate_limited", "Gave up after repeated rate limits.")
def deploy(plan: str, image: str, label: str) -> tuple[int, str]:
created = call(
"POST",
"/servers",
json={"plan": plan, "image": image, "label": label},
)
return created["server"]["id"], created["root_password"]
def wait_for_address(server_id: int, timeout: int = 600) -> str:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
server = call("GET", f"/servers/{server_id}")["server"]
if server["status"] == "running" and server["ipv4_address"]:
return server["ipv4_address"]
time.sleep(10)
raise TimeoutError(f"server {server_id} never came up")
Note the retry policy: 429 waits for Retry-After, 500 backs off, and every
other error raises straight away because waiting will not change the answer.