Building a Prefect Deployment for Nightly Composites
Deploy a flow with a cron schedule onto a work pool:
from prefect import flow
@flow(log_prints=True)
def nightly_composite(days_back: int = 30, tiles: list[str] | None = None, out_prefix: str = "s3://composites/"):
...
if __name__ == "__main__":
nightly_composite.from_source(
source="https://github.com/org/raster-pipelines.git",
entrypoint="flows/composite.py:nightly_composite",
).deploy(name="nightly", work_pool_name="raster-ecs", cron="0 2 * * *",
parameters={"days_back": 30}, image="ghcr.io/org/raster-flows:2026.09")
A flow that runs in a notebook becomes a service once it has a schedule, somewhere to run, and guarantees about what happens when two runs overlap or one fails. This page belongs to orchestrating pipelines with Prefect in Cloud Execution & Orchestration.
The Pieces of a Deployment
Separating the always-on worker from the per-run infrastructure is what keeps a nightly pipeline cheap. The worker is a lightweight process that polls for scheduled runs; when one is due, it launches an ECS task or Kubernetes job sized for compositing, which exits when the flow finishes. Nothing large sits idle for the other twenty-two hours of the day.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
prefect |
>=3.0 |
Flows, deployments, work pools, artifacts |
prefect-aws |
>=0.5 |
ECS work pools and S3 blocks |
odc-stac |
>=0.3.9 |
Loading scenes inside the flow |
rioxarray |
>=0.15 |
Writing composite COGs |
pip install "prefect>=3.0" "prefect-aws>=0.5" "odc-stac>=0.3.9" "rioxarray>=0.15"
prefect work-pool create raster-ecs --type ecs
prefect worker start --pool raster-ecs
Complete Working Example
# flows/composite.py
import datetime as dt
from prefect import flow, task, get_run_logger
from prefect.artifacts import create_table_artifact
from prefect.concurrency.sync import concurrency
@task(retries=2, retry_delay_seconds=[60, 300], tags=["stac-read"])
def composite_tile(tile: str, start: dt.date, end: dt.date, out_prefix: str) -> dict:
key = f"{out_prefix}{end:%Y/%m/%d}/{tile}.tif"
if exists(key): # idempotent: skip finished tiles
return {"tile": tile, "status": "skipped", "key": key}
with concurrency("stac-api", occupy=1): # global cap on catalogue load
items = search(tile, start, end)
if not items:
return {"tile": tile, "status": "no_data", "key": None}
cube = load(items, tile)
write_cog(median_composite(cube), key)
return {"tile": tile, "status": "ok", "key": key, "scenes": len(items)}
@flow(log_prints=True)
def nightly_composite(days_back: int = 30, tiles: list[str] | None = None,
out_prefix: str = "s3://composites/", run_date: dt.date | None = None):
log = get_run_logger()
end = run_date or dt.date.today()
start = end - dt.timedelta(days=days_back)
tiles = tiles or default_tiles()
futures = composite_tile.map(tiles, start, end, out_prefix)
results = [f.result(raise_on_failure=False) for f in futures]
rows = [r if isinstance(r, dict) else {"tile": t, "status": "failed", "key": None}
for t, r in zip(tiles, results)]
create_table_artifact(key="composite-manifest", table=rows,
description=f"Composite {start} to {end}")
failed = [r["tile"] for r in rows if r["status"] == "failed"]
log.info(f"{len(rows) - len(failed)} ok/skipped, {len(failed)} failed")
if len(failed) > max(5, 0.01 * len(rows)):
raise RuntimeError(f"{len(failed)} tiles failed")
Three design choices make this safe to run unattended. Outputs are written to keys derived from the run date and tile, and existing outputs are skipped, so a rerun after a failure only does the missing work. A global concurrency limit caps how many tasks hit the STAC API at once, however many tiles map in parallel. And the flow fails only when failures exceed a threshold, so a handful of problem tiles produce a successful run with a manifest listing them rather than a red run that hides the 99% that worked. The retry settings follow retrying failed raster tasks in a Prefect pipeline.
Schedules, Time Zones and Backfills
Cron schedules are evaluated in UTC unless a time zone is given, which matters twice a year when daylight saving shifts a 02:00 local run by an hour. Setting the time zone explicitly on the schedule keeps runs at the intended local time. More important is making the run date a parameter rather than reading the clock inside the flow: a backfill for missed nights is then just a run with run_date set, and reprocessing last month after a bug fix is a loop over dates. A flow that calls date.today() deep inside a task can never reproduce yesterday.
Choosing the Work Pool
Process work pools run flows on the worker’s own machine and suit small pipelines or development. Docker pools isolate each run in a container on the worker’s host. ECS and Kubernetes pools launch each run as a separate task or job with its own CPU and memory, which is the right choice for compositing: each night’s run gets a machine sized for it, and the worker host stays small. The image should contain GDAL and the flow’s dependencies, built as in building a slim GDAL Docker image, with the tag pinned in the deployment so a new image cannot change last night’s behaviour without a new deployment.
Preventing Overlapping Runs
If a run takes longer than a day — because of a backlog, a slow catalogue or a large backfill — the next scheduled run starts while the previous one is still writing. With idempotent outputs this is wasteful rather than dangerous, but it doubles load on the catalogue and the cluster. A deployment-level concurrency limit of one queues the second run until the first finishes; alternatively, a tag-based limit shared by several deployments caps the combined load on a shared resource. Alerting on runs that exceed their usual duration, as in alerting on failed tile jobs, catches the backlog before it compounds.
Verification
prefect deployment inspect 'nightly-composite/nightly'
prefect deployment run 'nightly-composite/nightly' --param run_date=2026-09-01 --param tiles='["33UVP"]'
# run the same command again: the manifest should show status "skipped" for 33UVP
Common Errors
Scheduled runs never start
No worker is polling the work pool. Start a worker for that pool and keep it running as a service.
Runs start an hour off after a clock change
The schedule has no time zone. Set it explicitly.
A rerun reprocesses everything
Outputs are not deterministic or not checked. Derive keys from run date and tile and skip existing ones.
Two runs overlap and double the load
No concurrency limit on the deployment. Set a limit of one.
Frequently Asked Questions
Q: What is a Prefect deployment? A server-side registration of a flow with its entry point, default parameters, schedule and the work pool its runs should use. It turns a flow into something that runs on a schedule or on demand.
Q: Which work pool suits raster compositing? An ECS or Kubernetes pool, so each run gets its own appropriately sized container and nothing large sits idle between runs.
Q: How do I backfill missed nights? Make the run date a flow parameter, then trigger runs for the missed dates. With idempotent outputs, a backfill is just ordinary runs.
Q: Should a few failed tiles fail the whole run? Usually not. Fail only above a threshold and list the failed tiles in a manifest artifact, so partial success is visible and recoverable.
Related
- Orchestrating Pipelines with Prefect — the parent topic.
- Parameterizing Prefect Flows for Multi-Tile Runs — the parameters this deployment exposes.
- Scheduling Sentinel-2 Downloads with Prefect Flows — an upstream scheduled flow.
- Choosing a Pixel-Selection Rule for Composites — what the composite task computes.