---
sidebar_label: Scheduled tasks
toc_max_heading_level: 3
doc_id: 7e3a1f4c-9b2d-4a8e-b5c6-d0f1e2a3b4c5
description: >-
  Run periodic jobs on Kubernetes using the Scheduled tasks scope type,
  with built-in support for cron scheduling, concurrency control, and retries.
keywords:
  - scheduled tasks
  - CronJob
  - Kubernetes
  - batch jobs
  - nullplatform scopes
  - workflow overrides
---

# Scheduled tasks scope

The Scheduled tasks scope runs periodic jobs on Kubernetes using CronJobs. Use it for any workload that runs on a schedule, does its work, and exits without exposing a network endpoint.

## When to use it

Use the Scheduled tasks scope when your workload runs on a recurring schedule, doesn't serve HTTP traffic, and performs a finite task before exiting, such as ETL pipelines, report generation, data cleanup, or batch event processing.

## What it includes

The Scheduled tasks scope provides built-in support for:

- **Cron scheduling and concurrency control**: define when jobs run using cron expressions, and control whether concurrent runs are allowed, replaced, or queued
- **Retries and job history**: set retry attempts for failed jobs and configure how many runs to keep
- **Resource limits and logging**: define CPU and memory per job, with logs streamed through the same pipeline as other scopes

## How it works

Scheduled tasks are built as an override of the [Containers](/docs/agent-backed-scopes/containers) scope. Instead of creating a long-running Deployment with networking, the scope:

1. Replaces the Kubernetes Deployment with a **CronJob**.
2. Skips DNS, Service, and Ingress provisioning (no network endpoint needed).
3. Manages secrets, resources, and logs just like a Containers scope.

```mermaid
flowchart LR
  A[Cron triggers] --> B[Job created]
  B --> C[Pod runs task]
  C --> D[Task completes]
  D --> E[Pod exits]
```

## Get started

To set up a Scheduled tasks scope, you need a Kubernetes cluster, [Helm](https://helm.sh/docs/intro/install/), [Gomplate](https://docs.gomplate.ca/), the [nullplatform CLI](/docs/cli/), and an [API key](/docs/authorization/api-keys) with agent roles.

The setup process follows these steps:

1. Install the nullplatform agent in your cluster using Helm.
2. Clone the [scopes repository](https://github.com/nullplatform/scopes) and set `SERVICE_PATH=scheduled_task`.
3. Configure your environment variables (API key, NRN, environment).
4. Run the `./configure` script to register the scope schema, actions, and notification channel.
5. Create your first Scheduled tasks scope from the UI.

The [Scheduled tasks tutorial](/docs/tutorials/scheduled-task) provides the full walkthrough with commands and checkpoints.

## How the overrides work

The Scheduled tasks scope customizes the base Containers scope through configuration and behavior overrides. This section explains what each override does, so you can understand the implementation or further customize it for your needs.

### Repo structure

Place workflow overrides under `scope/workflows/` or `deployment/workflows/` depending on the lifecycle action you're customizing.

```
your-override-repo/
├── scope/                  # Scope lifecycle actions
│   └── workflows/
│       ├── create.yaml
│       ├── update.yaml
│       └── delete.yaml
├── deployment/
│   └── templates/
│       ├── deployment.yaml.tpl
│   └── workflows/
│       ├── initial.yaml
│       ├── blue_green.yaml
├── values.yaml             # Your configuration overrides
└── ..
```

### Skip DNS configuration

Scheduled jobs don't need a service or DNS record. The `create` and `delete` workflows skip the networking step:

```yaml
# path: scope/workflows/create.yaml
include:
  - "$SERVICE_PATH/values.yaml"
steps:
  - name: networking
    action: skip
```

```yaml
# path: scope/workflows/delete.yaml
include:
  - "$SERVICE_PATH/values.yaml"
steps:
  - name: networking
    action: skip
```

### Use a CronJob instead of a Deployment

In Kubernetes, scheduled jobs use `CronJob` objects. The override replaces the base Deployment template with a CronJob template in `deployment/templates/deployment.yaml.tpl`:

```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: job-{{ .scope.id }}-{{ .deployment.id }}
  namespace: {{ .k8s_namespace }}
  labels:
    name: d-{{ .scope.id }}-{{ .deployment.id }}
    app.kubernetes.io/part-of: {{ .namespace.slug }}-{{ .application.slug }}
    nullplatform: "true"
spec:
  schedule: "{{ .scope.capabilities.cron }}"
  concurrencyPolicy: {{ .scope.capabilities.concurrency_policy }}
  successfulJobsHistoryLimit: {{ .scope.capabilities.history_limit }}
  failedJobsHistoryLimit: {{ .scope.capabilities.history_limit }}
  jobTemplate:
    metadata:
      labels:
        name: d-{{ .scope.id }}-{{ .deployment.id }}
        app.kubernetes.io/part-of: {{ .namespace.slug }}-{{ .application.slug }}
        nullplatform: "true"
    spec:
      backoffLimit: {{ .scope.capabilities.retries }}
      template:
        metadata:
          labels:
            name: d-{{ .scope.id }}-{{ .deployment.id }}
            app.kubernetes.io/part-of: {{ .namespace.slug }}-{{ .application.slug }}
            nullplatform: "true"
          annotations:
            nullplatform.logs.cloudwatch: 'true'
            nullplatform.logs.cloudwatch.log_group_name: {{ .namespace.slug }}.{{ .application.slug }}
            nullplatform.logs.cloudwatch.log_stream_log_retention_days: '7'
            nullplatform.logs.cloudwatch.log_stream_name_pattern: >-
              type=${type};application={{ .application.id }};scope={{ .scope.id }};deploy={{ .deployment.id }};instance=${instance};container=${container}
            nullplatform.logs.cloudwatch.region: us-east-1
        spec:
          restartPolicy: OnFailure
          securityContext:
            runAsUser: 0
          containers:
            - name: application
              image: {{ .asset.url }}
              envFrom:
                - secretRef:
                    name: s-{{ .scope.id }}-d-{{ .deployment.id }}
              resources:
                limits:
                  cpu: {{ .scope.capabilities.cpu_millicores }}m
                  memory: {{ .scope.capabilities.ram_memory }}Mi
                requests:
                  cpu: {{ .scope.capabilities.cpu_millicores }}m
                  memory: {{ .scope.capabilities.ram_memory }}Mi
```

Then, point your `values.yaml` to this new template:

```yaml
configuration:
  DEPLOYMENT_TEMPLATE: "$OVERRIDES_PATH/deployment/templates/deployment.yaml.tpl"
```

### Modify the deployment workflow

Because the scope no longer uses `Ingress` or `Service`, the deployment workflow skips the `route traffic` step and replaces the deployment logic with one that applies the CronJob template.

In `deployment/workflows/initial.yaml`:

```yaml
include:
  - "$SERVICE_PATH/values.yaml"
steps:
  - name: route traffic
    action: skip

  - name: create deployment
    type: script
    action: replace
    file: "$OVERRIDES_PATH/deployment/build_deployment"
    output:
      - name: DEPLOYMENT_PATH
        type: file
        file: "$OUTPUT_DIR/deployment-$SCOPE_ID-$DEPLOYMENT_ID.yaml"
      - name: SECRET_PATH
        type: file
        file: "$OUTPUT_DIR/secret-$SCOPE_ID-$DEPLOYMENT_ID.yaml"

  - name: apply
    type: script
    file: "$SERVICE_PATH/apply_templates"
    configuration:
      ACTION: apply
      DRY_RUN: false

    post:
      name: wait deployment active
      action: skip
```

### Refresh the agent sources

After changing your override repo, [refresh the agent sources](/docs/agent/agent-refresh) so it pulls the latest code.

### Result

With these overrides, your scheduled task scope will:

- Skip DNS creation and deletion
- Use a Kubernetes `CronJob` instead of a `Deployment`
- Remove unnecessary networking steps
- Still manage secrets, resources, and logs

You can find the complete implementation in the [scopes repository](https://github.com/nullplatform/scopes/tree/main/scheduled_task).

## Next steps

- [Override workflows](/docs/agent-backed-scopes/overrides): customize scope behavior for your organization
- [Containers](/docs/agent-backed-scopes/containers): the default scope type for long-running workloads
- [Scope types](/docs/agent-backed-scopes/scope-types): compare all available scope types
