Skip to content
Console →
Website →
Asking an AI? Paste this URL https://keelson.dev/llms.txt

Scheduled Jobs (Cron)

Keelson can run code automatically on a schedule as well as serve an application. You manage jobs from the same codebase and the same keelson.yaml as your web application, making them useful for automating routine business operations.

Common use cases include:

  • generating a morning report and saving sales or KPI totals to a database;
  • synchronizing an external API with your database every hour;
  • sending daily Slack reminders about overdue tasks or pending approvals;
  • importing accepted CSV data in a nightly batch;
  • periodically deleting old records; and
  • draining work that the web application recorded for later processing.

The crons section is the only background execution mechanism available to an application. The former workers: declaration has been retired, and Keelson does not provide an API for submitting arbitrary asynchronous tasks. How Apps Run explains when server-side code is guaranteed to run and which common patterns that rules out.

Scheduled Jobs do not consume the plan quota for concurrently available web applications. They are counted separately from web applications.


  1. Define a job under crons in keelson.yaml.
  2. Deploy the application, and Keelson registers the scheduled job.
  3. Keelson runs the configured command according to its schedule.
  4. Keelson records the result and logs for each execution.

A job starts from the same codebase and image as the web application, but it runs in a separate instance. It does not share memory or local disk with the web application. Keelson does not provide a writable /data directory; use /tmp only for temporary files that may be discarded when the execution ends.

ConfigurationBehavior
Cron only, with no top-level commandRuns as a job-only application and does not consume the concurrent web-app quota
Web application plus cron, with a top-level commandRuns the web application and each job in separate instances; shared state belongs in a durable store

The job definitions use the same format in both configurations.

  • The dashboard shows whether each execution succeeded or failed.
  • Standard output and standard error are recorded as logs.
  • Usage includes runs that actually execute, whether they succeed, fail, time out, or are triggered manually. An occurrence skipped before the container starts does not consume the monthly execution quota.
  • A failed cron execution is not automatically retried. It waits for the next scheduled occurrence.

Define jobs in the crons section of keelson.yaml:

slug: my-app
runtime: python-slim
db:
mode: none
crons:
- name: daily-report
schedule: "0 9 * * *"
command: "python report.py"
timeout: 120
FieldRequiredDefaultDescription
nameYesJob name: 1–63 lowercase letters, numbers, and hyphens
scheduleYesFive-field cron expression
commandYesCommand to execute
timeoutNo300-second schema defaultTimeout from 1–600 seconds; the effective default is capped at the plan maximum

The number of jobs allowed in one application depends on its plan: Starter 3, Plus 5, and Team 10.

crons:
- name: hourly-sync
schedule: "0 * * * *"
command: "python sync.py"
- name: daily-cleanup
schedule: "0 3 * * *"
command: "python cleanup.py"
timeout: 60
- name: weekly-report
schedule: "0 9 * * 1"
command: "python weekly_report.py"
timeout: 600

The 600-second timeout in the last example requires the Team plan.

Environment variables declared in the top-level env section are also available while a job runs.

db:
mode: libsql
secrets:
items:
- name: SLACK_WEBHOOK_URL
description: "Slack incoming webhook URL"
required:
- all_of: [SLACK_WEBHOOK_URL]
crons:
- name: notify
schedule: "0 9 * * *"
command: "python notify.py"

The schedule is a standard five-field cron expression:

┌───────────── minute (0-59)
│ ┌─────────── hour (0-23)
│ │ ┌───────── day of month (1-31)
│ │ │ ┌─────── month (1-12)
│ │ │ │ ┌───── day of week (0-6, 0 = Sunday)
│ │ │ │ │
* * * * *
GoalCron expressionMeaningMinimum plan
Every day at 9:000 9 * * *Run at 9:00 each dayStarter
Every 30 minutes*/30 * * * *Run at minute 0 and 30 of every hourPlus
Every hour0 * * * *Run at minute 0 of every hourStarter
Every 10 minutes*/10 * * * *Run at ten-minute intervalsTeam
Weekdays at 9:000 9 * * 1-5Run Monday through Friday at 9:00Starter
First day of each month0 0 1 * *Run at midnight on the first dayStarter
Every day at 3:000 3 * * *A common nightly batch scheduleStarter
Every minute* * * * *Syntax example only; no current plan permits this intervalNot available

Cron schedules are evaluated in the workspace time zone, which is detected automatically from the browser when the workspace is created. For example, 0 9 * * * runs at 09:00 in the workspace time zone.


Aggregate and save sales data every morning

Section titled “Aggregate and save sales data every morning”
  • Input: sales records in Managed SQLite (db.mode: libsql)
  • Processing: aggregate the day’s records and calculate totals
  • Output: save the result in a reports table
crons:
- name: daily-sales
schedule: "0 8 * * *"
command: "python aggregate_sales.py"
timeout: 120

Synchronize data from an external API every hour

Section titled “Synchronize data from an external API every hour”
  • Input: an external service’s REST API
  • Processing: fetch the latest data and apply changes to Managed SQLite
  • Output: updated rows in the relevant table
crons:
- name: hourly-sync
schedule: "0 * * * *"
command: "python sync_from_api.py"
timeout: 180
  • Input: database records
  • Processing: delete rows whose expired_at value is in the past
  • Output: write the number of deleted rows to the log
crons:
- name: cleanup-expired
schedule: "0 2 * * *"
command: "python cleanup_expired.py"
timeout: 60
  • Input: weekly data from the database
  • Processing: render a report from a template
  • Output: save the report through an object-storage API
crons:
- name: weekly-report
schedule: "0 9 * * 1"
command: "python generate_weekly_report.py"
timeout: 300

The 300-second timeout requires a Plus or Team plan. On Starter, use at most 180 seconds or split the report into smaller batches.


A job runs repeatedly and may be interrupted. Design it so that running the same operation again does not create duplicate data or corrupt existing state.

# Avoid an unconditional INSERT that creates a duplicate on every run.
db.execute("INSERT INTO reports (date, total) VALUES (?, ?)", (today, total))
# Prefer an UPSERT that safely replaces the row for the same date.
db.execute("""
INSERT INTO reports (date, total) VALUES (?, ?)
ON CONFLICT(date) DO UPDATE SET total = excluded.total
""", (today, total))

The schema default is 300 seconds, but the effective timeout is the lower of that value and the plan maximum. For example, omitting timeout on Starter gives an effective timeout of 180 seconds. An explicitly configured value must be in the absolute 1–600 second range and at or below the plan maximum; a larger value is rejected during deployment.

If work cannot finish in ten minutes, limit the number of records handled in one run and let later scheduled executions continue with the remainder.

Plan for failure without automatic retries

Section titled “Plan for failure without automatic retries”

Keelson does not automatically rerun a failed job. An idempotent job can recover naturally on its next scheduled execution. For critical jobs, also record a failure signal or send an alert to a service such as Slack.

When a job calls an external API, account for that service’s rate limits. A hypothetical every-minute schedule would quickly exceed many providers’ allowances, although current Keelson plans do not permit that interval.

Coordinate concurrent Managed SQLite writes

Section titled “Coordinate concurrent Managed SQLite writes”

The web process and every cron use the same Managed SQLite database. A cron can write while the web process is writing, and different crons can overlap when their execution times cross. The libSQL primary accepts one writer at a time, so write contention can fail an execution with errors such as SQLITE_BUSY or TRANSACTION_TIMEOUT.

Keep write transactions short and split large updates into bounded batches. If several processes update the same rows, serialize those writes in application logic or use a small, bounded retry with backoff for transient contention. Make the operation idempotent before retrying it; Keelson does not automatically retry a failed cron execution.

The web process and cron process do not share a local SQLite file or filesystem. Use Managed SQLite for shared relational state and the Files SDK for durable files. Files written to /tmp are ephemeral. Keelson does not provide a writable /data directory.


Standard output, such as Python print calls or JavaScript console.log calls, and standard error are captured separately. Each stream is limited to 256 KiB. If a stream exceeds that limit, Keelson discards the excess and appends an …[output truncated at 262144 bytes]… marker to the captured text. Output near the end of a very verbose run, including a final exception or summary, may therefore be absent. Open the dashboard to inspect the execution history and captured logs for an individual job.

Log useful progress information and record counts so a failure can be located without reproducing the entire run.

import datetime
print(f"[{datetime.datetime.now()}] job started")
# ... process records ...
print(f"processed records: {count}")
print(f"[{datetime.datetime.now()}] job completed")
  1. Read the execution log for an error message and stack trace.
  2. Check environment variables such as API credentials and database URLs.
  3. Check the timeout and confirm the command finishes within it.
  4. Reproduce locally by running the same command in a comparable environment.

You can give the Keelson job log directly to an AI agent and ask it to identify the cause, update the code, and redeploy the application.

Review this job error log, identify the cause, and fix the application.


Web applications, scheduled jobs, and durable data can work together in one Keelson application. The database, rather than process memory or local disk, is the handoff point between the web and cron instances.

Pattern: admin interface and nightly batch

Section titled “Pattern: admin interface and nightly batch”

Use the web application to enter settings and data, and let a nightly job handle the heavier aggregation work.

slug: sales-tool
runtime: python-slim
command: "python app.py"
db:
mode: libsql
env:
PYTHONUNBUFFERED: "1"
crons:
- name: nightly-aggregate
schedule: "0 2 * * *"
command: "python aggregate.py"
timeout: 300

The 300-second timeout requires a Plus or Team plan. Starter accepts a maximum of 180 seconds.

Declare Python dependencies in requirements.txt; Keelson installs them during the build. The web application must listen on the PORT environment variable injected by Keelson rather than declaring its own value.

  • During the day, users enter sales data through the admin interface.
  • At night, the job aggregates the data and updates report rows.
  • The next morning, the interface reads and displays those rows.

Pattern: process uploaded CSV records periodically

Section titled “Pattern: process uploaded CSV records periodically”

Record each accepted upload as a pending database row. A job can claim those rows periodically, process them, and write back the result.

crons:
- name: process-csv
schedule: "*/30 * * * *"
command: "python process_inbox.py"
timeout: 180

This 30-minute schedule requires a Plus or Team plan. Starter jobs must be scheduled at least 60 minutes apart.

  • The web application creates a pending row for the accepted CSV.
  • Every 30 minutes, the job processes pending rows and updates their status.
  • The web application displays or downloads the completed result.

Store the uploaded content with the Files SDK when the job needs the original file. Keep its key in Managed SQLite; do not pass it through a local directory.

crons:
- name: daily-summary
schedule: "0 7 * * *"
command: "python generate_summary.py"
timeout: 600

This 600-second timeout requires the Team plan.

  • At 7:00 each morning, the job summarizes the previous day’s data with an AI API.
  • The job stores the summary in Managed SQLite.
  • The web application displays summaries by date.

The field order is minute, hour, day of month, month, and day of week. To run at 9:00, use 0 9 * * *. The reversed 9 0 * * * runs at 00:09.

# Wrong for 9:00: this runs at 00:09.
schedule: "9 0 * * *"
# Correct: this runs at 09:00.
schedule: "0 9 * * *"

Output written to /tmp is not available to a later execution. A cron also runs separately from the web instance, and Keelson does not provide a writable /data directory. Put durable relational data in db.mode: libsql, and use an appropriate Keelson SDK for durable file content: Files for private files or Media for content served to app members.

A command that works locally can fail on Keelson if its API key exists only in your local environment. Configure environment variables and secrets for the deployed application instead of embedding credentials in source code.

A job that sends many requests every minute can be rejected by the external service. Increase the interval, batch the work, or limit requests within a run.

Scheduled Jobs have monthly execution quotas: Starter 1,000, Plus 5,000, and Team 15,000. After the quota is reached, remaining occurrences for the month are skipped. Only runs that actually execute count toward usage; skips caused by the quota, a previous active run, a disabled cron, or a stopped application do not increase the used count. When a cron or application is disabled, the scheduler may be removed entirely, so no skip record is guaranteed for each tick.

Hypothetically, an every-minute schedule would have about 43,200 occurrences in a 30-day month. No current plan permits that schedule, even for testing: the shortest available interval is five minutes on the Team plan. Keelson validates the plan’s minimum interval during deployment and rejects * * * * *.


This configuration runs a job without a web application:

slug: my-cron
runtime: python-slim
db:
mode: none
env:
PYTHONUNBUFFERED: "1"
crons:
- name: heartbeat
schedule: "0 * * * *"
command: "python heartbeat.py"
timeout: 30

heartbeat.py:

import datetime
print(f"OK: {datetime.datetime.now()}")

This hourly schedule is accepted by every current plan.

Python: aggregate sales data in Managed SQLite

Section titled “Python: aggregate sales data in Managed SQLite”

This cron-only sample uses Managed SQLite. Add the following files alongside aggregate_sales.py.

keelson.yaml:

slug: daily-sales
runtime: python-slim
db:
mode: libsql
env:
PYTHONUNBUFFERED: "1"
crons:
- name: daily-sales
schedule: "0 8 * * *"
command: "python aggregate_sales.py"
timeout: 120

requirements.txt:

libsql==0.1.11

Keelson injects KEELSON_DB_URL and KEELSON_DB_AUTH_TOKEN because the deploy declares db.mode: libsql. The example expects the web application or a prior migration to have created and populated sales, and to have created reports with a unique date column.

aggregate_sales.py:

import datetime
import json
import os
import libsql
conn = libsql.connect(
database=os.environ["KEELSON_DB_URL"],
auth_token=os.environ["KEELSON_DB_AUTH_TOKEN"],
)
today = datetime.date.today().isoformat()
row = conn.execute(
"SELECT COUNT(*), SUM(amount) FROM sales WHERE date = ?", (today,)
).fetchone()
report = {"date": today, "count": row[0], "total": row[1] or 0}
conn.execute(
"""
INSERT INTO reports (date, payload) VALUES (?, ?)
ON CONFLICT(date) DO UPDATE SET payload = excluded.payload
""",
(today, json.dumps(report)),
)
conn.commit()
print(f"aggregation complete: {report}")

This cron-only sample needs these files alongside sync.mjs.

keelson.yaml:

slug: catalog-sync
runtime: node-slim
db:
mode: libsql
env:
SYNC_API_URL: "https://api.example.com/items"
crons:
- name: hourly-sync
schedule: "0 * * * *"
command: "node sync.mjs"
timeout: 180

Replace the example SYNC_API_URL with the HTTPS endpoint to synchronize. If the endpoint needs a credential, declare the credential under secrets and set its value separately instead of committing it under env.

package.json:

{
"private": true,
"dependencies": {
"@libsql/client": "0.17.4"
}
}

Run npm install --package-lock-only after creating package.json, and commit the generated package-lock.json so the build resolves the same dependency version. Keelson injects the two database environment variables for db.mode: libsql.

sync.mjs:

import { createClient } from "@libsql/client";
const db = createClient({
url: process.env.KEELSON_DB_URL,
authToken: process.env.KEELSON_DB_AUTH_TOKEN,
});
const API_URL = process.env.SYNC_API_URL;
async function sync() {
const res = await fetch(API_URL);
const items = await res.json();
await db.execute(`
CREATE TABLE IF NOT EXISTS synced_items (
id TEXT PRIMARY KEY,
data TEXT,
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
for (const item of items) {
await db.execute({
sql: `INSERT INTO synced_items (id, data) VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET
data = excluded.data,
synced_at = CURRENT_TIMESTAMP`,
args: [item.id, JSON.stringify(item)],
});
}
console.log(`synchronized ${items.length} records`);
}
sync().catch((err) => {
console.error("synchronization failed:", err);
process.exit(1);
});
import datetime
import json
import os
import urllib.request
slack_webhook = os.environ["SLACK_WEBHOOK_URL"]
message = {"text": f"Daily report is ready: {datetime.date.today()}"}
request = urllib.request.Request(
slack_webhook,
data=json.dumps(message).encode(),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(request)
print("Slack notification sent")

keelson.yaml:

slug: daily-notifier
runtime: python-slim
db:
mode: none
env:
PYTHONUNBUFFERED: "1"
secrets:
items:
- name: SLACK_WEBHOOK_URL
description: "Slack incoming webhook URL"
required:
- all_of: [SLACK_WEBHOOK_URL]
crons:
- name: daily-notify
schedule: "0 9 * * 1-5"
command: "python notify.py"
timeout: 30

Declare only the secret name in keelson.yaml; never commit its value.

The CLI can set a secret only after the application exists. For an existing application, set the value and immediately redeploy it with --apply:

Terminal window
printf '%s' "$SLACK_WEBHOOK_URL" | keelson secrets set SLACK_WEBHOOK_URL --apply

For a new application, run keelson deploy --new once. The required-secret check blocks that first deployment, but the application remains created. Store the value without --apply, because there is no completed deployment to redeploy yet, and then deploy the same source tree again without --new:

Terminal window
printf '%s' "$SLACK_WEBHOOK_URL" | keelson secrets set SLACK_WEBHOOK_URL --app daily-notifier
keelson deploy

Use crons not only for a particular time of day, but also for work that should be drained every few minutes. Define one short-interval schedule and let each execution process a bounded number of pending records before exiting.

slug: mailer
runtime: python-slim
command: "python app.py" # Web: create a pending row
db:
mode: libsql # Durable state shared by web and cron
env:
PYTHONUNBUFFERED: "1"
crons:
- name: drain
schedule: "*/5 * * * *" # Team: drain every five minutes
command: "python drain.py" # Process pending rows and exit 0
timeout: 120

The five-minute schedule requires the Team plan. On Plus, use at least 15 minutes; on Starter, use at least 60 minutes.

  1. Queue work in database rows and drain it idempotently. The web side writes a pending row to Managed SQLite. The cron claims pending rows, performs the work, and updates processed_at or status. Make repeating any item harmless, because a timeout can leave it for the next run.
  2. Do not use an in-process scheduler. APScheduler, node-cron, FastAPI BackgroundTasks, setInterval, and similar mechanisms assume a resident process. Keelson applications can scale to zero, so move periodic work into a crons declaration.
  3. Use only durable services for shared state. Put durable relational state in Managed SQLite and private durable files in the Files SDK. A file written to a cron’s local disk is discarded and is never visible to the web application.

A run is stopped after at most 600 seconds. When the amount of work is variable, set a maximum batch size and leave the rest for the next schedule. If the previous execution is still active, the overlapping occurrence is skipped.


Limits for crons vary by plan.

LimitStarterPlusTeam
Cron entries per app3510
Minimum cron interval60 min15 min5 min
Execution timeout maximum3 min5 min10 min
Scheduled Jobs executions per month1,0005,00015,000
  • Every timeout also has an absolute 600-second maximum. The lower of the plan limit and the absolute maximum applies.
  • A schedule more frequent than the plan’s minimum interval is rejected.
  • The monthly quota counts runs that actually execute, not skipped occurrences. After it is reached, remaining occurrences are skipped without increasing the used count until the quota resets the following month.

Keelson rejects a deployment when its crons configuration does not fit the execution model or plan. The structured error includes a code and a hint that you can pass to an AI agent along with the configuration.

SituationResolution
workers: is declared (workers_not_supported)Delete the block and rewrite the operation as a crons entry
timeout is above 600 secondsReduce it to 600 or less
timeout is above the plan maximumReduce it to the plan limit or upgrade the plan
The schedule is more frequent than the plan permitsIncrease the interval or upgrade the plan
The application defines too many cron entriesCombine jobs, remove entries, or upgrade the plan

  • Routine business automation: daily or hourly aggregation, notifications, data synchronization, and cleanup.
  • Workflows shared with a web application: interactive data entry during the day followed by scheduled batch processing.
  • Periodic external integration: fetching from APIs or sending scheduled webhooks.
  • Regular data maintenance: removing old records and creating reports.
  • Immediate event-driven processing: finish small work within the request or use an external task service when an event must trigger work immediately.
  • Jobs longer than ten minutes: split the work into bounded batches handled over multiple executions.
  • Arbitrary asynchronous tasks submitted by the application: Keelson does not provide a task-submission API in the initial release.
  • Complex job chains: use a dedicated workflow engine when jobs require dependencies, branching, or orchestration.
  • Second-level precision: five-field cron expressions schedule by the minute.

No. The job runs again at its next scheduled occurrence. Make the command idempotent so the next execution can safely recover unfinished work.

Can I run only a job, without a web application?

Section titled “Can I run only a job, without a web application?”

Yes. Omit the top-level command and define one or more crons entries to deploy a job-only application.

The dashboard shows each job’s execution history, success or failure status, and logs.

What happens when the monthly quota is reached?

Section titled “What happens when the monthly quota is reached?”

Remaining occurrences in that month are skipped. The quota resets the next month. Limits are Starter 1,000, Plus 5,000, and Team 15,000 occurrences per month.

Cron schedules are evaluated in the workspace time zone. This setting is detected automatically from the browser when the workspace is created.


Add a job that aggregates sales data every morning at 9:00. Define it in the crons section of keelson.yaml.

Create a batch process that fetches an external API every 30 minutes and synchronizes the result to Managed SQLite. This schedule requires Plus or a higher plan.

Add a cleanup job that deletes old records every day at 3:00.