deadbeat docs

deadbeat runs a Python script on a schedule, in a container of its own, without you owning a server. This is everything you need to know to get a job running and keep it running.

The loop

  1. Create a job and paste a script. One file. It runs as python main.py. If you don't have one handy, start from a template on the dashboard - each is a complete working script.
  2. List anything you import that isn't in the standard library. Put it in the requirements.txt box next to the script, one per line, the same as a normal requirements.txt. We install it into the container before running. If you skip this and your script imports requests, we'll tell you before you waste a run.
  3. Run it once. You get stdout, stderr, the exit code and the duration straight away.
  4. Give it a schedule. This is the part that makes it a cron job rather than a script you ran once.

File history

Every save that changes your script or your requirements.txt keeps the version it replaced. Open a job, expand settings, then version history: each entry can be viewed, and any earlier one restored. Restoring saves that older file as a new version rather than erasing anything, so the history only ever grows and you can always get back to where you were before you restored.

Each run also records which version it executed. A run from three weeks ago shows the code that actually ran, not whatever the job holds today - so a run that failed before a fix still shows the script that failed.

Two things are deliberately not versioned. Secret values are not, so restoring an old script never quietly puts back an API key you have since rotated - the restore leaves your environment variables exactly as they are. Neither is the timeout, because each run already records the limit that applied to it.

One thing to know before you rely on it. History keeps what you removed, including a key you pasted into the script and then edited out: that older version still holds it, and you can still open it. Deleting the job takes its whole history out of the product, but that is not the same as erasing it: we can still recover it, so deleting will not make a leaked key unrecoverable. There is no undo button for you - ask us. So put secrets in environment variables rather than in the script - that is what they are for, and their values are never versioned. If one did end up in a script, treat it the way you would a key committed to git: rotate it. Removing the version would not have helped either.

Jobs run at /try without signing in have no history, because there is no way to edit them. Sign in from the same browser and that work moves onto your account, keeping the runs it already did, and it starts keeping history from the version it arrives with.

Scripts that never exit

A job is a script that runs to completion and stops. deadbeat starts a fresh container on every tick, waits for your script to finish, and records what it printed. The schedule is the loop.

So a script built to stay up - a while True watcher, a polling bot, a run_forever server, anything that waits for Ctrl+C - will always be killed at its timeout, and raising the timeout only changes how long it takes. The ceiling is 800 seconds and it will not help.

The fix is usually small, because the inside of the loop is already the job. Delete the loop and the sleep, do one pass, and exit:

# instead of this
while True:
    check_prices()
    time.sleep(60)

# do this, on a * * * * * schedule
check_prices()

Anything the old loop kept in memory between passes has to go somewhere that survives, because every tick gets a brand new container. Write it to /out, and it comes back at /in on the next run.

from pathlib import Path
import json

# Same habit as the output example above, so this runs on your laptop too.
inbox = Path("/in") if Path("/in").is_dir() else Path(".")
outbox = Path("/out") if Path("/out").is_dir() else Path(".")

prior = inbox / "seen.json"
seen = set(json.loads(prior.read_text())) if prior.is_file() else set()

# ... do the work, adding to seen ...

(outbox / "seen.json").write_text(json.dumps(sorted(seen)))

/in holds the files from your last successful run that actually wrote something, and it is read-only - you update state by writing the new version to /out, never by editing /in. A run that failed is skipped on purpose: a crashed run usually wrote nothing, and handing that back would wipe the state you were keeping.

So the usual early return is safe. This keeps your state:

if not new_items:
    return          # nothing to do, nothing written, state untouched

The trade-off is the other direction: writing nothing means "unchanged", so to clear your state you write an empty file rather than no file. And the limits match /out - 10 files, 1 MB total. If the whole set cannot be restored, /in arrives empty rather than half-filled, which is the same thing your first run sees.

/tmp is not a substitute. It is scratch for the length of one run, wiped between runs, so writing state there succeeds and reads back empty next time with nothing to tell you why. Nor is writing next to your script: the root filesystem is read-only, so /app/state.json fails with OSError: [Errno 30] Read-only file system. /out and /tmp are the only writable paths.

One caveat worth knowing: file contents are stored exactly as written - not encrypted, and not scrubbed. An output file is a work product, and rewriting bytes inside it would corrupt a download meant to be exact. So keep tokens in environment variables, which are encrypted, and out of the files you write.

Schedules

Pick a preset - every 15 minutes, hourly, daily, weekly, monthly - or write a standard five-field cron expression. Cron expressions are interpreted in UTC, with no per-job timezone, so a "9am daily" job is 9am UTC wherever you are.

A job with no schedule is still useful: it sits there and runs when you click run, which is a fine way to keep a script you run by hand.

Step values don't mean what they look like

This catches everyone once. */15 in the minutes field does not mean "every 15 minutes". It means "every minute divisible by 15" - so :00, :15, :30, :45 - and the count restarts at the top of every hour.

With 15 that works out even, because 15 divides into 60. With a step that doesn't, the last interval of each hour is short. */16 fires at :00, :16, :32, :48 and then :00 again - so the gaps are 16, 16, 16, and then 12:

07:16  →  07:32  →  07:48  →  08:00  →  08:16
     16m       16m       12m       16m

Nothing is broken when that happens; it's what the expression always meant. If you want genuinely even spacing, use a step that divides into 60: 1, 2, 3, 4, 5, 6, 10, 12, 15, 20 or 30. And when you type a schedule into a job, deadbeat shows you the next few times it will actually fire - worth a glance before saving.

There is a better trick if you also want to stay off the busy minutes. */15 puts you on :00, :15, :30 and :45, which is where practically every scheduled job in the world already is. Listing the minutes explicitly gives you even spacing and an offset:

1,16,31,46 * * * *

That's every 15 minutes, shifted one minute past the quarter hour, with no hourly reset to trip over because each slot is named outright. Any offset works - 7,22,37,52 is the same idea. Nothing here is oversubscribed today, so this is politeness rather than necessity, but it costs nothing and it's a good habit on any shared scheduler.

Output and files

Everything your script prints is captured per run - the last 50KB of stdout and stderr each - and kept in the run history.

Anything your script writes to /out comes back as a file you can view or download from that run. That's the way to produce a report, a CSV, a scraped dataset. A good habit is to check the directory exists first, so the same script still works on your laptop:

from pathlib import Path

out = Path("/out")
if out.is_dir():
    (out / "report.md").write_text(brief)
else:
    print(brief)

Files are kept per job against a size budget, oldest evicted first, so a daily job writing a small file keeps years of history. The files from your most recent successful run are never evicted, so the state your job reads back from /in cannot be aged out from under it.

Secrets

API keys and tokens go in the job's environment variables, not in the script. Read them the ordinary way:

import os

token = os.environ["API_TOKEN"]

Where they actually live

Encrypted at rest, with a key that is not stored in the database alongside them. Decrypted into memory for the seconds your run executes and handed to the container as environment variables - never baked into an image layer, so they are not in the stored image either, and never written to our logs.

You can read your own values back. Open the job's edit form and the stored values are in the boxes, so changing one key does not mean retyping the others. That form is the only place a value is ever returned - not the job listing, not the run output, not any other endpoint. It also means anyone holding your signed-in session can read your keys, the same as any dashboard that shows you your own credentials.

Two things that follow from this, both worth knowing before you paste a live key in. If your script prints a secret, it lands in your own run output - we are handing the value to your process and what it does with it is yours. And if your script lets an exception carrying a secret escape - a URL with a token in the path is the classic - that text is captured the same way. We strip values we know about, but handle your own errors rather than relying on it.

When something breaks

If a scheduled run fails, we email you. Once when it breaks, again at intervals while it stays broken, and once more when it recovers. Manual runs don't email - you're watching those happen.

Exit non-zero when your job fails. This is the one thing worth getting right. A script that catches an exception, logs it, and exits 0 looks like a healthy run forever while doing nothing - the exact failure this product is named after. If the work didn't happen, say so:

import sys

if rows_written == 0:
    print("no rows written", file=sys.stderr)
    sys.exit(1)

Failed to build

A build failure means your script never ran. Before a job executes, we install your requirements.txt into its container. If that install fails there is nothing to run, so the status is failed to build rather than failed, and there is no stdout, no stderr and no exit code from your code - it was never started. Nothing is charged against your job for a build that never produced a run.

Open the run and read the build log panel. That is pip's own output, verbatim, and it names the line it could not satisfy. The two things it usually says:

ERROR: Could not find a version that satisfies the
       requirement reqeusts (from versions: none)
ERROR: No matching distribution found for reqeusts

from versions: none means no package of that name exists - almost always a typo, or the import name rather than the package name (import yaml installs as pyyaml, import bs4 as beautifulsoup4, import cv2 as opencv-python). If it lists versions instead, the name is fine and the pin is not: you asked for one that was never published.

A package that compiles C from source can also fail here, with a long error: subprocess-exited-with-error block. Pinning a version that publishes a wheel for Linux usually fixes it, because then nothing needs compiling.

If the build log says the failure happened before your requirements.txt, that one is ours, not yours. It means we could not prepare the container at all. Nothing you change in your job will help, so tell us instead.

Out of memory

Every run gets 256MB, and unlike the timeout this is not adjustable per job. If your script needs more than that, the kernel stops it the moment it asks for more.

It leaves no traceback. The process is killed outright rather than raised in, so there is no exception to catch and stderr is empty. Your stdout just stops mid-sentence, wherever the script had got to. We detect this and say so on the run, because on its own it looks identical to an ordinary crash.

The usual causes, in rough order of how often they turn up:

  • Accumulating everything before using it. Appending every row, response or record to one list and processing it at the end. Memory grows with the whole job rather than with one item.
  • Loading a full dataset to use part of it. Reading an entire file or API response into memory when you only need a running total, the last value, or rows matching a filter.
  • Keeping per-item history you no longer need. Dataframes, parsed JSON or fetched pages held in a dict long after the loop has moved on.

The fix is almost always the same shape: handle one item at a time and keep only the result. Write to /out as you go rather than at the end, accumulate a total instead of a list, and let each iteration's data go out of scope.

If several of your jobs are killed at once, it may not be you. The signal we use also fires when the host itself runs short of memory and picks a process inside your container. A single job that dies consistently at the same point is almost certainly its own memory use; a cluster of unrelated jobs dying together is worth telling us about.

Limits

  • Python 3.12.
  • One script per job - no multi-file projects or imports between jobs.
  • Runs are capped at 800 seconds and 256MB of memory.
  • Your script can reach the internet, but not other jobs or anything private on our side.
  • Scripts, dependencies and output all have size caps; you'll be told if you hit one rather than silently truncated.

What deadbeat does not do

Listed so you find out here rather than halfway through building something on top of it.

  • No CLI and no git push. Scripts are pasted or edited in the browser. There is no deploy step to wire into CI.
  • No language but Python, and no choice of version.
  • No pipelines. One script per job - no DAGs, no job that waits on another job, no passing output from one to the next.
  • Nothing it runs is reachable over the network. A job has no URL, so there are no webhooks into one. It can call out; nothing can call in.
  • No per-job timezones. Cron is interpreted in UTC, and nothing shifts for daylight saving.
  • No diagnosis. We hand you the exit code and the output. Reading them is your job - there is no LLM here explaining your traceback back to you.

And the one we would rather you heard from us

deadbeat does not yet notice a schedule that silently stops firing. A run that fails emails you. A tick that never happens leaves no run at all, and nothing is currently watching for that gap. The product is named after exactly that failure and does not detect it yet.

Until it does, the practical defence is the one in When something breaks: exit non-zero when the work did not happen, so the runs that do fire tell you the truth about themselves.

Stuck?

Email hello@deadbeat.dev, or just reply to any email deadbeat sends you - both reach a person.