Scheduling a daily report from a Flask app with Encore, Flask-APScheduler, rq-scheduler or Google Cloud Scheduler, with jobs that are safe to run twice.
Flask has no scheduler, so recurring work like a daily report or a cleanup ends up in a thread inside the web process, a separate worker, or a crontab on one server. Each choice decides how many times the job runs once the app scales past one process.
This guide schedules a daily signup report in a Flask app with Encore, Flask-APScheduler, rq-scheduler and Google Cloud Scheduler. Each version runs at 06:00 UTC and is safe to run twice for the same day.
What you need to decide:
Encore combines scheduled jobs and the other infrastructure in your Flask code with a local environment and a deploy to your own AWS or GCP account, where a managed scheduler calls each job. It is an infrastructure SDK added to an existing app, and the routes stay ordinary Flask routes.

A cron job names the path to call and the schedule. When it is due, Encore sends a POST request to that path:
from encore import cron
from flask import Flask
app = Flask(__name__)
# Encore sends POST /internal/daily-report to this service every day at 06:00 UTC.
daily_report = cron.Job(
"daily-report",
path="/internal/daily-report",
title="Email yesterday's signup report",
schedule="0 6 * * *",
)
The route keys the run on the day it reports, so a retried or doubled call does nothing. The set stands in for a table with a unique date column:
from datetime import date, timedelta
sent_reports: set[str] = set() # Stand-in for a table keyed by report date.
@app.post("/internal/daily-report")
def send_daily_report():
day = (date.today() - timedelta(days=1)).isoformat()
# A retried or doubled run for the same day does nothing.
if day in sent_reports:
return {"day": day, "sent": False}
sent_reports.add(day)
return {"day": day, "sent": True}
The app is served with Waitress on the address Encore assigns:
import os
from waitress import serve
if __name__ == "__main__":
host, _, port = os.environ.get("ENCORE_LISTEN_ADDR", "0.0.0.0:8000").rpartition(":")
serve(app, host=host or "0.0.0.0", port=int(port))
Cron jobs do not fire on a schedule under encore run, so you test by calling the route. The second call for the same day reports sent: false:
On deploy, the schedule is created in your own cloud account and each run gets a trace, so a failed report shows the request that failed. Adding every="1h" instead of schedule gives a fixed interval.
When an agent adds a job, the schedule, the route and the test request sit in one file, and the deploy creates the schedule in the cloud.
The scheduler calls one instance per run, so the job does not multiply as the service scales. Schedules are in UTC. Keep the work short or hand long work to a queue, so a run finishes before the next one starts.
Flask-APScheduler runs APScheduler inside the Flask process and reads jobs from config:
from flask import Flask
from flask_apscheduler import APScheduler
class Config:
JOBS = [
{
"id": "daily-report",
"func": "reports.jobs:send_daily_report",
"trigger": "cron",
"hour": 6,
}
]
app = Flask(__name__)
app.config.from_object(Config())
scheduler = APScheduler()
scheduler.init_app(app)
scheduler.start()
Every process that imports the app starts a scheduler, so four workers send four reports. Production setups run it in a separate single process, and jobs stop when that process restarts or scales to zero.
rq-scheduler stores schedules in Redis and enqueues jobs for RQ workers:
from redis import Redis
from rq_scheduler import Scheduler
from reports.jobs import send_daily_report
scheduler = Scheduler(connection=Redis.from_url("redis://localhost:6379"))
scheduler.cron("0 6 * * *", func=send_daily_report, id="daily-report")
You run Redis, one rqscheduler process and RQ workers. Schedules live in Redis rather than the repository, so the registration code runs on each deploy and needs a stable id to avoid duplicates.
Cloud Scheduler calls an HTTP endpoint on a cron schedule:
gcloud scheduler jobs create http daily-report \
--schedule="0 6 * * *" --time-zone=UTC \
--uri="https://reports-abc123-ew.a.run.app/internal/daily-report" \
--http-method=POST \
--oidc-service-account-email="scheduler@$PROJECT.iam.gserviceaccount.com"
The scheduler account needs the Cloud Run Invoker role on the service, and the route should check the OIDC token. The schedule lives in GCP rather than the code, and nothing runs it during local development.
Python support in Encore is in beta, and you can sign up for access to try it with your Flask app.
Expose the job as a route that is safe to run twice and have one scheduler call it. With Encore, declare cron.Job("daily-report", path="/internal/daily-report", title="Email yesterday's signup report", schedule="0 6 * * *") next to a Flask route on that path. After deploy, Encore calls it on schedule in your AWS or GCP account.
Each Gunicorn or Waitress process that imports the app starts its own scheduler. With several workers or instances, each one runs the job. Run the scheduler in one dedicated process or use an external scheduler.
Call the job's route directly. With Encore, cron jobs do not fire on a schedule under encore run, so you POST to the path while the app runs locally and check the result.
Key each run on the period it covers, such as the report date, and record it under a unique constraint. A retried or doubled run for the same period finds the record and does nothing.
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.