Blog
Engineering

Stop paying for an idle server to run Python scripts

DK
Dan Kowalsky
Founder ·

A lot of Python scripts live on a small server that does nothing most of the day. A cron job wakes up, runs for a few minutes, and the machine sits idle until tomorrow. You pay for it around the clock, and someone has to keep it patched, backed up and working.

Here's one of those jobs, and what happened when we moved it off its server.

The job

It finds local businesses in the UK that have no website, checks who they really are, and builds a mailing list for postcards. That means thousands of searches and page fetches: Google Maps, a search API, Companies House and the businesses' own sites. The rules are careful, every decision keeps its evidence, and anything ambiguous is dropped rather than guessed.

How it ran on the server

It was a command-line tool on the cheapest VPS tier: 1 vCPU and 1–2 GB of memory, about $6 a month. Running it meant:

  • SSH into the box, then reattach to the right tmux session.
  • Run each stage through a wrapper script that backed up a SQLite database first.
  • Let a nightly cron job slowly work through a ledger of map cells.
  • Keep API keys in a .env file on the server.
  • Follow a long runbook, because a zero exit code didn't prove a stage had actually worked.

Even the AI help had to live on the server. A normal chat couldn't reach it, so Claude Code was installed on the box and run inside tmux, one session at a time, so two of them wouldn't write to the same SQLite file.

The server bill was small. The real cost was that only someone who knew the box could run the job.

What it looks like now

Each stage is a Python function with @deploy. In the Callables web app, they're five forms that pass a spreadsheet along:

  1. Find prospects: a town or postcode and a radius in, prospects.csv out.
  2. Verify websites: checks each business for a real website and returns verified.csv.
  3. Check companies: matches them against Companies House and returns review.csv, with a drop column to mark.
  4. Recheck before print: runs the checks again close to the print date.
  5. Build mailing list: takes the reviewed sheet and the suppression list, and returns mailing-list.csv.

Here's the shape of step 1:

from typing import Literal

from callables import File, Group

prospects = Group("prospects", title="1. Find prospects")


@prospects.deploy(title="1. Find prospects", secrets=["PLACES_API_KEY"], next=["verify_websites"])
def find_prospects(
    town_or_postcode: str,
    radius_km: float = 10,
    search_method: Literal["Fast", "Original"] = "Fast",
) -> File:
    """Searches Google Maps around a UK town and keeps the businesses that look like a fit."""
    ...

The type hints become the form. next= puts a button on the result that opens the next step, with the file already attached.

What it costs now

Callables charges for what a run actually uses, measured every second: $0.03 per vCPU-hour and $0.015 per GB-hour. Between steps, nothing runs and nothing is charged.

This job spends most of its time waiting on APIs and web pages, so it uses a fraction of a CPU. The Free plan includes $1 of usage a month, which covers a lot of runs like these.

That comparison holds for jobs that sit idle most of the day. A machine that's busy around the clock is still cheaper as a VPS.

What changed besides the bill

No database. The spreadsheet is the state. Each step takes the previous step's CSV and returns a new one. Every row keeps its status, so the sheet shows the whole funnel, including what was dropped and why. There's nothing to back up.

Review happens in the sheet. Command-line flags for marking rows became "type an x in the drop column", then upload the file to the next step. Anyone who can use a spreadsheet can do the review.

Long steps fit the run limit. A step stops with time to spare and says how many rows are left. Upload its output again to carry on. A business is never half-checked.

One search instead of a nightly ledger. Rather than cron chipping away at map cells, step 1 searches the whole area in one run. On a simulated Google, the new search found every business with 66 requests instead of 394.

Secrets live on the project page. There are no keys in a .env file on a server, and the people running a step never see them.

Evidence for every verdict. Each step also saves an evidence CSV, listing every search and page behind its decisions.

Keeping the logic honest

The rules that decide whether a website really belongs to a business were ported from JavaScript to Python. Before trusting the port, we ran both versions on 3,000 random inputs and got the same answer every time, and every check in the original smoke test is in the Python tests. The port also fixed one bug: a Facebook page hidden behind a Google redirect had been counted as a real website.

Try it on your own idle server

If you've got a Python job on a server that's idle most of the day, move it to Callables. Add @deploy, push to GitHub, and it runs only when it's needed.

Turn your scripts into tools your team can run

Add @deploy to a Python function and push. Your team gets a form, schedules and run history.

See how it works