Nov 13, 20267 min read

How to Add Cron Jobs to a chi App

Run scheduled tasks next to a go-chi API, once per schedule across every replica, with Encore, robfig/cron or a scheduled ECS task.

Illustration for How to Add Cron Jobs to a chi App

A chi API is good at answering requests and has nothing to say about work that should happen on a schedule, like ending free trials when they expire or sending a reminder email three days before. Go makes it tempting to start a ticker or a cron library in main, which works until the service runs on two instances and every job runs twice, or until it scales to zero and the job never runs at all.

This guide adds two scheduled jobs to a chi accounts API: an hourly job that moves ended trials to the free plan and a daily reminder for trials about to end. It starts with Encore, where the schedule is declared in Go and the job is called once per schedule rather than once per instance, in your own AWS or GCP account, then covers robfig/cron in process and a scheduled ECS task.

Cron options for a chi app at a glance

Encorerobfig/cron in processEventBridge Scheduler + ECS task
Schedule declared inGo code (cron.NewJob)Go codeTerraform or the console
With several replicasCalled once per scheduleRuns once per replicaStarted once per schedule
Job codeA private endpoint in the serviceA function in mainA separate command or container
Local behaviorNot scheduled, call the endpoint to testRuns on scheduleRun the command by hand
Run historyCron Jobs page in the Encore dashboardYour logsCloudWatch and ECS task history

How do you add cron jobs to chi with Encore?

Encore

With Encore working alongside chi, the router keeps serving HTTP from a raw endpoint with a fallback route, and each scheduled job is a private Encore endpoint in the same service, sharing its database. The schedule sits right above the job as a cron.NewJob declaration, and Encore calls the endpoint on that schedule in deployed environments.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create accounts-api --example=hello-world$ cd accounts-api && go get github.com/go-chi/chi/v5@v5.5.0$ mkdir -p accounts/migrations

The accounts table tracks the plan, when the trial ends and whether the reminder went out:

-- accounts/migrations/1_create_accounts.up.sql CREATE TABLE accounts ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, plan TEXT NOT NULL DEFAULT 'trial', trial_ends_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '14 days', reminder_sent_at TIMESTAMPTZ );

The chi router handles signups, and the two jobs are plain functions with //encore:api private, so they cannot be called from the internet. Every must divide 24 hours evenly and Schedule takes a standard cron expression in UTC:

// accounts/accounts.go package accounts import ( "context" "encoding/json" "net/http" "encore.dev/cron" "encore.dev/rlog" "encore.dev/storage/sqldb" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) var db = sqldb.NewDatabase("accounts", sqldb.DatabaseConfig{ Migrations: "./migrations", }) //encore:service type Service struct { router chi.Router } func initService() (*Service, error) { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Post("/accounts", startTrial) return &Service{router: r}, nil } // Fallback sends every request that no Encore endpoint matches to chi. // //encore:api public raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.router.ServeHTTP(w, req) } func startTrial(w http.ResponseWriter, r *http.Request) { var in struct { Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Email == "" { http.Error(w, "email is required", http.StatusBadRequest) return } var id int64 err := db.QueryRow(r.Context(), ` INSERT INTO accounts (email) VALUES ($1) ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email RETURNING id `, in.Email).Scan(&id) if err != nil { http.Error(w, "could not create account", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]int64{"id": id}) } // Every hour, move accounts whose trial has ended to the free plan. var _ = cron.NewJob("expire-trials", cron.JobConfig{ Title: "Expire ended trials", Every: 1 * cron.Hour, Endpoint: ExpireTrials, }) // ExpireTrials is idempotent: a second run finds nothing left to update. // //encore:api private func ExpireTrials(ctx context.Context) error { res, err := db.Exec(ctx, ` UPDATE accounts SET plan = 'free' WHERE plan = 'trial' AND trial_ends_at < now() `) if err != nil { return err } rlog.Info("expired trials", "count", res.RowsAffected()) return nil } // Every day at 08:00 UTC, remind accounts whose trial ends within three days. var _ = cron.NewJob("trial-reminders", cron.JobConfig{ Title: "Send trial ending reminders", Schedule: "0 8 * * *", Endpoint: SendTrialReminders, }) // SendTrialReminders marks each account before emailing it, so an account // is reminded at most once even if the job runs twice. // //encore:api private func SendTrialReminders(ctx context.Context) error { rows, err := db.Query(ctx, ` UPDATE accounts SET reminder_sent_at = now() WHERE plan = 'trial' AND reminder_sent_at IS NULL AND trial_ends_at BETWEEN now() AND now() + interval '3 days' RETURNING email, trial_ends_at::date::text `) if err != nil { return err } defer rows.Close() for rows.Next() { var email, endsOn string if err := rows.Scan(&email, &endsOn); err != nil { return err } rlog.Info("trial reminder", "to", email, "ends_on", endsOn) // send with your email provider } return rows.Err() }

Both jobs are written so a second run does nothing harmful. ExpireTrials only touches rows still on the trial plan, and SendTrialReminders stamps reminder_sent_at in the same statement that selects who to email, which means each account gets at most one reminder even if the job runs twice. If you would rather retry a reminder whose email failed, stamp the row after the send instead and accept a rare duplicate.

Cron jobs do not run during local development or in preview environments, so you test them by calling accounts.ExpireTrials from the local development dashboard while encore run provides the Postgres database. Once deployed to your own AWS or GCP account, the Encore platform runs the jobs with no extra cloud infrastructure, and each execution shows up on the Cron Jobs page of the Encore dashboard. The job endpoints are ordinary Go functions, so an agent can add a job, call it locally against the local database and write a test for it in one change.

Best fit: chi services that need scheduled work called once per schedule no matter how many instances run, next to the code and database it uses, in your own AWS or GCP account.

robfig/cron inside the chi process

robfig/cron, the long-standing Go cron library, gives you a scheduler that starts in main next to the chi server, with functions registered by cron expressions or descriptors like @hourly:

package main import ( "context" "log" "net/http" "os" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/robfig/cron/v3" ) func main() { pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) if err != nil { log.Fatal(err) } c := cron.New(cron.WithLocation(time.UTC)) c.AddFunc("@hourly", func() { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if _, err := pool.Exec(ctx, ` UPDATE accounts SET plan = 'free' WHERE plan = 'trial' AND trial_ends_at < now()`); err != nil { log.Printf("expire trials: %v", err) } }) c.Start() defer c.Stop() r := chi.NewRouter() log.Fatal(http.ListenAndServe(":8080", r)) }

This is the quickest route on a single always-on instance, and it costs nothing extra to run. Every replica starts its own scheduler, so with three tasks behind a load balancer the hourly job runs three times, which you then guard with a Postgres advisory lock or a lease row. Jobs also stop when the process stops, so a deploy at the wrong minute skips a run, and a platform that scales to zero never fires them. Logging and alerting on failures is up to you, since the library only calls your function.

Best fit: single-instance services where an occasional skipped or doubled run is acceptable.

EventBridge Scheduler and a scheduled ECS task

On AWS, the managed route is EventBridge Scheduler starting an ECS task on a cron expression, with the job built as a separate command in the same image, such as /app expire-trials. The ECS guide for scheduled tasks covers the pieces: a task definition, a scheduler role allowed to run it and pass the task roles, and the subnets and security groups the task starts in. Runs happen once regardless of how many API replicas exist, and the task has its own CPU and memory, which suits long jobs.

The trade-off is a second entry point to keep in sync with the API's database code and configuration, plus a schedule that lives in Terraform rather than next to the job, and nothing to run locally except calling the command yourself.

Best fit: heavy batch jobs that need their own resources and run on an AWS setup already managed in Terraform.

How to choose

Pick Encore when you want the jobs declared in the same Go package as the chi app, called once per schedule across every instance in your own AWS or GCP account, with run history in the dashboard. Use robfig/cron on a single always-on instance where a skipped run is fine. Use EventBridge Scheduler with ECS tasks for long batch work in an existing Terraform setup.

Frequently asked questions

How do I run a cron job in a go-chi app?

chi only routes HTTP requests, so the schedule has to come from somewhere else: a library such as robfig/cron inside the process, or an external scheduler that calls an endpoint or starts a task. With more than one replica, the in-process option runs the job once per replica unless you add locking.

How do I add a cron job to a chi app with Encore?

Declare the job with cron.NewJob("expire-trials", cron.JobConfig{Title: "Expire ended trials", Every: 1 * cron.Hour, Endpoint: ExpireTrials}) and write ExpireTrials as a private Encore endpoint with the signature func(context.Context) error. The chi router keeps serving HTTP through a raw endpoint with path=/!fallback. Cron jobs do not run in local development or preview environments, so you test one by calling its endpoint from the local development dashboard; they run on schedule once the app is deployed.

Why should a cron job be idempotent?

Schedulers can call a job twice, for example after a network error, and a job that has failed halfway is often rerun by hand. Writing the job as a conditional UPDATE, such as setting plan = 'free' only where the trial has ended and the plan is still trial, makes a second run harmless.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders