Skip to content

Gradual Rollouts

A gradual rollout raises a feature flag's rollout percentage over time, in controlled stages. Instead of flipping a flag from 0% to 100% in one step, you roll out to 10%, watch for problems, expand to 50%, confirm everything is healthy, then go to 100%.


What a Rollout Schedule Is

A rollout schedule is a plan attached to a feature flag. It defines:

  • a sequence of stages, each with a target rollout percentage;
  • a trigger type for each stage: time-based (the scheduler starts it at its start_date, once the stages before it are complete) or manual (it starts only when you advance it);
  • optional start and end dates for the whole schedule.

The platform's background scheduler processes active schedules every 15 minutes (ROLLOUT_CHECK_INTERVAL_MINUTES) and starts a time-based stage once its start_date has passed and every stage before it is complete. Once the schedule's end_date has passed, the scheduler passes the schedule over: no stage starts or completes by itself, and the schedule stays active.


Creating a Rollout Schedule via API

Run these in one terminal, in order, against the stack from the Quick Start. Each step uses the shell variables set by the ones before it.

URLs. The collection URL ends with a slash (/api/v1/rollout-schedules/). Without it the API answers 307 Temporary Redirect, which curl doesn't follow, so nothing happens and nothing is printed.

Step 1: Log in

Changing a flag's rollout takes a user with the ADMIN or DEVELOPER role:

TOKEN=$(curl -s -X POST localhost:8000/api/v1/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"admin@demo.com","password":"Demo1234!"}' | jq -r .access_token)

curl -s localhost:8000/api/v1/auth/me -H "Authorization: Bearer $TOKEN" | jq .role

It prints "ADMIN".

Step 2: Create the flag

A schedule belongs to a flag. This creates one, switched on at 0%, and saves its id in $FLAG_ID (see Creating Feature Flags for the details):

FLAG=$(curl -s -X POST localhost:8000/api/v1/feature-flags/ \
  -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"key": "new-checkout-flow", "name": "New Checkout Flow", "rollout_percentage": 0, "is_active": true}')
FLAG_ID=$(jq -r .id <<<"$FLAG")

jq '{key, status, rollout_percentage}' <<<"$FLAG"

It prints "status": "active" and "rollout_percentage": 0.

Step 3: Create the rollout schedule

This schedule has three stages:

  1. Initial Canary, 10%, manual: you start it when you're ready.
  2. Expanded Rollout, 50%, time-based, with a start_date a week from now. It comes after a manual stage, so it does not start by itself (see Trigger Types).
  3. Full Rollout, 100%, manual: a human approval gate before everyone gets the feature.

Dates are UTC, in ISO 8601 form. The first two lines work them out from today with jq, so the example always uses dates in the future. In your own schedule you can write them out (2027-01-08T00:00:00Z). The command saves the schedule's id in $SCHEDULE_ID, and the first stage's in $CANARY_ID:

WEEK1=$(jq -nr 'now + 7*86400 | todate')
WEEK4=$(jq -nr 'now + 28*86400 | todate')

SCHEDULE=$(curl -s -X POST localhost:8000/api/v1/rollout-schedules/ \
  -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  --data @- <<EOF
{
  "name": "New Checkout Flow Gradual Rollout",
  "description": "Roll out the new checkout to all users over 4 weeks",
  "feature_flag_id": "$FLAG_ID",
  "end_date": "$WEEK4",
  "max_percentage": 100,
  "min_stage_duration": 24,
  "stages": [
    {
      "name": "Initial Canary",
      "description": "First 10%: watch error rates",
      "stage_order": 1,
      "target_percentage": 10,
      "trigger_type": "manual"
    },
    {
      "name": "Expanded Rollout",
      "description": "Expand to 50%: confirm stability",
      "stage_order": 2,
      "target_percentage": 50,
      "trigger_type": "time_based",
      "start_date": "$WEEK1"
    },
    {
      "name": "Full Rollout",
      "description": "100%: manual gate for final approval",
      "stage_order": 3,
      "target_percentage": 100,
      "trigger_type": "manual"
    }
  ]
}
EOF
)
SCHEDULE_ID=$(jq -r .id <<<"$SCHEDULE")
CANARY_ID=$(jq -r '.stages[] | select(.stage_order == 1) | .id' <<<"$SCHEDULE")

jq -c '{status, stages: [.stages[] | {stage_order, target_percentage, trigger_type, status}]}' <<<"$SCHEDULE"

The API answers 201 Created. A new schedule is a draft, and every stage is pending:

{"status":"draft","stages":[{"stage_order":1,"target_percentage":10,"trigger_type":"manual","status":"pending"},{"stage_order":2,"target_percentage":50,"trigger_type":"time_based","status":"pending"},{"stage_order":3,"target_percentage":100,"trigger_type":"manual","status":"pending"}]}

The response also carries the schedule's id, feature_flag_id, owner_id, dates and settings, and each stage's id. A schedule is refused with 422 when its target percentages decrease, when two stages have the same stage order or the orders leave a gap, or when a stage order is above 1000. The orders need not start at 1: 5, 6, 7 is accepted.

Step 4: Activate the schedule

curl -s -X POST localhost:8000/api/v1/rollout-schedules/$SCHEDULE_ID/activate \
  -H "Authorization: Bearer $TOKEN" | jq .status

It prints "active". From now until the schedule's end_date, the scheduler starts a time-based stage once its start_date has passed and every stage before it is complete. Activating a schedule doesn't change the flag: it is still at 0%.


Rollout Stages

Target Percentage

Each stage has a target_percentage: the rollout percentage the flag is set to when that stage starts. Percentages must not decrease from one stage to the next.

  • Valid: 10 → 50 → 100.
  • Invalid: 10 → 50 → 25.

Trigger Types

Trigger Type Description
time_based Before the schedule's end_date, the stage starts by itself once its start_date has passed and every stage before it is complete; with no start_date, on the scheduler's first pass after that. The scheduler completes it no sooner than 24 hours after it starts, or the hours its trigger_configuration gives as duration.
manual The stage starts only when you advance it.
metric_based Accepted, but not implemented in this release: such a stage never starts by itself.

The scheduler never completes a manual stage, so a time-based stage after a manual stage never starts by itself. Advance the manual stage a second time to move on (see Manual Advancement).

Stage Lifecycle

pending → in_progress → completed

When a stage starts, it becomes in_progress and the flag's rollout percentage is set to the stage's target. When the scheduler starts the next stage, the previous one becomes completed.


Time-Based Triggers

For time_based stages, set a start_date in UTC, in ISO 8601 form:

{
  "trigger_type": "time_based",
  "start_date": "2027-01-08T00:00:00Z"
}

The scheduler checks every 15 minutes, so a stage set for 09:00 UTC may not start until 09:15. Plan your schedule with this buffer in mind.

All dates are in UTC. If your business runs on a specific timezone, convert before setting dates.


Manual Advancement

Advance a manual stage to start it. This starts the canary:

curl -s -X POST localhost:8000/api/v1/rollout-schedules/stages/$CANARY_ID/advance \
  -H "Authorization: Bearer $TOKEN" | jq '{name, status}'

curl -s localhost:8000/api/v1/feature-flags/$FLAG_ID \
  -H "Authorization: Bearer $TOKEN" | jq .rollout_percentage

The stage is in_progress, and the flag is now at 10%.

Advancing a manual stage that is in progress completes it and starts the next stage at once, whatever that stage's trigger and start_date. The advance takes effect at once, and the schedule must be active. Only a manual stage can be advanced; advancing a time-based one answers 400. A manual advance is not held back by the earlier stages or by min_stage_duration: advancing the 100% stage first would put the flag at 100% at once. min_stage_duration (in hours) applies only to the stages the scheduler starts.

Manual stages are recommended for the final rollout to 100%: they serve as a human approval gate before full deployment.


Pausing a Schedule

If you observe problems during a rollout, pause the schedule to stop the automatic stages:

curl -s -X POST localhost:8000/api/v1/rollout-schedules/$SCHEDULE_ID/pause \
  -H "Authorization: Bearer $TOKEN" | jq .status

It prints "paused". Pausing does not change the flag's current rollout percentage; it only stops future stage transitions. A safety rollback pauses the flag's active schedule the same way, and it stays paused until you resume it. When you have investigated and resolved the issue, resume the schedule by activating it again:

curl -s -X POST localhost:8000/api/v1/rollout-schedules/$SCHEDULE_ID/activate \
  -H "Authorization: Bearer $TOKEN" | jq .status

It prints "active".


Monitoring Progress

View Schedule Status

curl -s localhost:8000/api/v1/rollout-schedules/$SCHEDULE_ID \
  -H "Authorization: Bearer $TOKEN" \
  | jq -c '{status, stages: [.stages[] | {name, target_percentage, status}]}'
{"status":"active","stages":[{"name":"Initial Canary","target_percentage":10,"status":"in_progress"},{"name":"Expanded Rollout","target_percentage":50,"status":"pending"},{"name":"Full Rollout","target_percentage":100,"status":"pending"}]}

In the dashboard, the flag's page shows the schedule read-only under Rollout schedule: its status, its name, and each stage with its trigger, target percentage and status. The Rollout percentage below it is the flag's percentage now.

To list every schedule for a flag, filter the collection by feature_flag_id:

curl -s "localhost:8000/api/v1/rollout-schedules/?feature_flag_id=$FLAG_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .total

It prints 1. The response is {"items": [...], "total", "skip", "limit"}.


Cancelling a Schedule

To stop a rollout entirely:

curl -s -X POST localhost:8000/api/v1/rollout-schedules/$SCHEDULE_ID/cancel \
  -H "Authorization: Bearer $TOKEN" | jq .status

It prints "cancelled". The flag's current rollout percentage is not changed. To roll back, change the flag itself (see Creating Feature Flags), or use a safety rollback: a rollback to 0% turns the flag off and pauses its schedule.


Common Gotchas

  • Rollout percentages must not decrease across stages.
  • You cannot delete stages from an active schedule: cancel the schedule first.
  • Stage orders must be sequential (1, 2, 3, with no gaps), and no higher than 1000.
  • Time-based start_date values are in UTC.
  • Manual stages must be advanced explicitly: they never start by themselves, even after the schedule's end_date.
  • A manual advance takes effect at once, whatever the earlier stages and min_stage_duration say.