ai.vostman.fi

My journey to a cloud native citizen

For a long time, I had wanted a powerful desktop in the cloud, with compute I would pay for only while I was using it. I wanted to return to my tools, projects and settings without keeping the whole machine running between sessions.

Vibe coding gave me a way to build and manage that setup. It also gave me another reason to want one: a place where a coding agent could work, with access to a real development environment.

My time for the project came in sporadic moments, mostly with just my phone at hand. The setup had to fit that way of working. I could give instructions, review results and pick up the project again when I had another moment.

The arrangement grew into a small persistent machine on UpCloud and an on-demand desktop on Verda. The desktop's compute could disappear while its disk stayed behind. The next instance would boot into the environment I had left on that disk.

Android connects to the Codex cloud and directly to codex-min in UpCloud over SSH. Android and codex-min reach verda-desktop through Tailscale. Both VMs run Codex and connect to the Codex cloud. Codex-min manages Verda through its API; the desktop uses a persistent disk.
In this article
  1. Codex on a small persistent VM
  2. API access to both providers
  3. Access from Android
  4. Persistent disk, replaceable compute
  5. Manual top-ups and automatic deletion

01Codex on a small persistent VM

Codex is the main interface for creating and managing the environment. It writes configuration and scripts, runs commands, checks results and connects to other machines through SSH. Almost all of the work is done through it.

The starting point was codex-min, a small Debian VM on UpCloud. Its plan cost €3 per month and provided one virtual CPU, 1 GB of RAM and 10 GB of disk. It runs Codex and the management tools independently of the larger Verda machine. Codex starts at boot, with remote control available for returning to the environment.

This machine executes the agent's commands and holds its working files. It does not need the resources of the machine it manages: SSH provides remote command execution, while the providers' APIs handle the infrastructure.

02API access to both providers

API credentials for UpCloud and Verda allow the management tools to inspect resources and carry out the provisioning and lifecycle operations I request.

This gives Codex access to both layers of the setup. Through an API, it can inspect an instance or request an authorized infrastructure change. Through SSH, it can configure the operating system and run commands inside that instance. I choose when to start paid compute and which type to use.

Codex also runs on verda-desktop, where it can work directly with the resources of the selected instance.

03Access from Android

On my phone, ConnectBot uses an SSH key to log in without entering the server account's password. The public key is installed on the servers; the private key stays on the phone. The management VM has its own SSH key for connections to the machines it manages.

SSH from Android to codex-min connects directly. SSH from either Android or codex-min to verda-desktop goes through Tailscale. This gives the Verda machine a stable device name despite changing public IP addresses.

SSH still authenticates the connection. Tailscale supplies the network path and device addressing. Its identity persists on the retained Verda disk, so the replacement machine can rejoin under the same name.

04Persistent disk, replaceable compute

The Verda setup keeps the operating system, installed tools, projects and settings on a persistent system disk. Compute can be deleted while that disk remains. The next instance boots from the existing disk.

I can choose which compute to attach to that disk for the next session. The affordable CPU option already provides 4 vCPUs and 16 GB of RAM. At the other end is a beast: 22 vCPUs, 120 GB of system RAM and an A100 with 80 GB of GPU memory.

When no VM is instantiated, I only pay for storage on Verda.

The Verda management code runs on codex-min. Verda API access uses a client ID and client secret:

python
credentials = {
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET",
}

API is our own HTTP client. Here is a compact version using Python's standard library, with credentials passed to the constructor. It handles token renewal, JSON requests and paginated resource lists:

python
import json
import time
import urllib.request
import uuid


class API:
    def __init__(self, credentials):
        self.credentials = credentials
        self.token = None
        self.expiry = 0

    def request(self, method, endpoint, data=None, token=None):
        headers = {"Content-Type": "application/json"}
        if token:
            headers["Authorization"] = "Bearer " + token
        request = urllib.request.Request(
            "https://api.verda.com/v1" + endpoint,
            method=method,
            headers=headers,
            data=json.dumps(data).encode() if data is not None else None,
        )
        with urllib.request.urlopen(request, timeout=30) as response:
            body = response.read()
            try:
                result = json.loads(body) if body else None
            except json.JSONDecodeError:
                result = str(uuid.UUID(body.decode().strip()))
            return result, response.headers

    def call(self, method, endpoint, data=None):
        if self.token is None or time.time() >= self.expiry:
            result, _ = self.request("POST", "/oauth2/token", {
                **self.credentials,
                "grant_type": "client_credentials",
            })
            self.token = result["access_token"]
            self.expiry = time.time() + max(0, result["expires_in"] - 60)
        return self.request(method, endpoint, data, self.token)[0]

    def listing(self, endpoint):
        items = []
        page = 1
        while True:
            result = self.call("GET", f"{endpoint}?page={page}&pageSize=100")
            if not result:
                return items
            items.extend(result)
            page += 1

Creation endpoints can also return a plain UUID, which the response decoder handles. The lifecycle script adds its retry and state-management logic around these operations.

The client is then instantiated and the saved lifecycle state loaded:

python
api = API(credentials)
state = load()

The API provides the compute catalogue, current availability and existing volumes. The script finds the retained disk by its saved ID and uses that disk's location:

python
types = api.call("GET", "/instance-types")
availability = api.call("GET", "/instance-availability")
volumes = api.listing("/volumes")

disk = next(d for d in volumes if d["id"] == state["disk_id"])
location = disk["location"]

To launch the A100, the creation request selects 1A100.22V. The CPU option uses CPU.4V.16G. The existing disk's ID goes in the image field, so the new instance boots from that disk:

python
requested_kind = "1A100.22V"

plan = {
    "instance_type": requested_kind,
    "hostname": "verda-desktop",
    "description": "Persistent development desktop",
    "location_code": location,
    "image": disk["id"],
    "is_spot": False,
    "ssh_key_ids": register_keys(api, public_keys()),
}

result = api.call("POST", "/instances", plan)

The request also supplies the registered SSH key IDs. Before sending it, the full controller checks availability and disk attachment, arms automatic cleanup and saves the session deadline.

05Manual top-ups and automatic deletion

The most important requirement was that getting distracted must not leave an expensive desktop running indefinitely. Automatic deletion for idle or overlong sessions was essential from the start.

For both UpCloud and Verda, it was essential to me that I could choose to top up my account only manually.

The controller runs on codex-min so it remains available after the Verda machine stops. It requests cleanup, verifies that the compute instance is gone and checks that the disk remains. Failed cleanup can be retried on a later check.

The deletion request targets the compute instance and explicitly leaves the list of volumes to delete empty, preserving the system disk:

python
api.call("PUT", "/instances", {
    "id": state["instance_id"],
    "action": "delete",
    "volume_ids": [],
})

The normal policy is thirty minutes without confirmed activity, with a twelve-hour hard maximum and checks every five minutes. The hard maximum applies even if the machine still appears active. Activity monitoring includes SSH sessions and Codex work. A running Codex daemon alone does not count as active work; the monitor also uses actual session-work timestamps to catch work completed between checks.

The resulting setup keeps Codex and the management tools available on the small VM, retains the working environment on disk and allocates larger compute for the sessions that need it.

Email contact