# Background jobs (/platform/self-hosting/background-jobs)

Scheduled workflows, polling triggers, connector syncs, and maintenance jobs depend on authenticated HTTP endpoints called by an external scheduler.

Helm enables CronJobs by default; Docker Compose includes a `cron` service. Both use `CRON_SECRET` and the same shipped schedules. Upload-session cleanup needs an additional operator-configured job, described below.

## Authentication \[#authentication]

Every endpoint is protected by `CRON_SECRET` and expects it as a bearer token:

```bash
curl -f -s -S --max-time 60 \
  -H "Authorization: Bearer $CRON_SECRET" \
  https://sim.yourdomain.com/api/schedules/execute
```

Generate it like the other secrets:

```bash
openssl rand -hex 32
```

Warning:

Background jobs require `CRON_SECRET`. Helm rejects a missing inline value when CronJobs are enabled. With a pre-created Secret or External Secrets, supply the key in that Secret; otherwise cron pods cannot start. Endpoints reject calls without the matching secret.

Point cron at an **internal** address where possible (the in-cluster Service, or `localhost` on a single node). These endpoints should not be reachable from the internet; if they are, `CRON_SECRET` is the only thing protecting them.

## Kubernetes \[#kubernetes]

CronJobs are enabled by default. Supply `CRON_SECRET` through the app Secret and ensure cron pods can reach the internal app Service.

```yaml
cronjobs:
  enabled: true
```

Each `curlimages/curl` pod calls the internal app Service, which routes the request to one replica. `concurrencyPolicy: Forbid` prevents overlapping Jobs for that CronJob; the app also handles overlap for detached work.

| Setting | Shipped behavior |
| - | - |
| Completed/failed Job history | Three successful and one failed Job |
| Finished-Job TTL | 600 seconds; collect logs before deletion |
| Job deadline | 300 seconds by default; outbox uses 900, idempotency cleanup 360 |
| Kubernetes retry limit | No chart `backoffLimit` key; Kubernetes defaults to six retries after the initial failure |
| Request retries | Three shell attempts, ten seconds apart; each uses `curl --retry 2 --retry-delay 5` |
| Request timeout | 60 seconds by default, with per-job overrides; outbox uses 810 seconds |

The Job deadline includes attempts and controller backoff. It can end a Job before the retry limit is reached and also stops a hung request.

Disable individual jobs you do not need — billing reconciliation is the obvious one on a self-hosted install:

```yaml
cronjobs:
  jobs:
    reconcileBillingSeats:
      enabled: false
```

Do not disable schedule execution, outbox processing, or subscription renewal unless you know you do not use the features behind them.

Check they are running:

```bash
kubectl get cronjobs -n simstudio
kubectl get jobs -n simstudio --sort-by=.metadata.creationTimestamp | tail
kubectl logs -n simstudio 'job/<job-name>'
```

Inspect stale schedules and failed Jobs. A stale `LAST SCHEDULE` points to controller, suspension, or deadline settings; a 401 in Job logs points to authentication. `Forbid` can skip a tick while an earlier Job remains active. Alert on successful completion as described in [Observability](/platform/self-hosting/observability).

## Docker Compose \[#docker-compose]

Generate a secret, then save it as `CRON_SECRET` in the `.env` file next to your Compose file. Replace an existing value rather than adding a duplicate. The app and scheduler must receive the same value.

```bash
openssl rand -hex 32
```

Recreate the services after updating `.env`:

```bash
docker compose -f docker-compose.prod.yml up -d
```

Without it the `cron` service logs exactly what to set — including a freshly generated value — and exits, leaving the rest of the stack running. Schedules live in `docker/crontab` and mirror `helm/sim/values.yaml` `cronjobs.jobs` one-for-one.

An older install may lack `CRON_SECRET`; add it to `.env` and run `up -d` to enable the scheduler.

The service runs [supercronic](https://github.com/aptible/supercronic) rather than the app image: it logs each job's output to the container log, forwards `SIGTERM` so `docker compose stop` is graceful, and will not start an iteration while the previous one is still running.

```bash
docker compose -f docker-compose.prod.yml logs -f cron
```

A healthy log line looks like:

```
level=info msg=starting iteration=0 job.schedule="*/1 * * * *"
level=info msg="job succeeded" iteration=0
```

The published cron image contains its crontab. Editing the repository copy and restarting that image does not change its jobs. To customize them, copy `docker/crontab` to an operator-owned file, edit it, and mount it with a Compose override:

```yaml
# cron-custom.yml
services:
  cron:
    volumes:
      - ./sim-crontab:/etc/sim/crontab:ro
```

```bash
docker compose -f docker-compose.prod.yml -f cron-custom.yml up -d cron
```

Alternatively, build a custom image from `docker/cron.Dockerfile` with your crontab and configure Compose to use it.

## Schedule upload-session cleanup \[#schedule-upload-session-cleanup]

The cleanup endpoint exists, but neither shipped scheduler calls it. Configure your own authenticated schedule for `/api/cron/cleanup-tasks` to expire upload sessions and reclaim abandoned upload state. It also dispatches task-retention cleanup according to configured policy.

For Helm, add an operator-owned job to your values file. This example runs hourly; choose a cadence and timeout for your workload:

```yaml
cronjobs:
  jobs:
    uploadSessionCleanup:
      enabled: true
      name: upload-session-cleanup
      schedule: "0 * * * *"
      path: /api/cron/cleanup-tasks
      requestTimeoutSeconds: 300
      activeDeadlineSeconds: 360
      concurrencyPolicy: Forbid
```

For Compose, add this line to the mounted or custom-image crontab described above:

```text
0 * * * * curl -fsS -m 300 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/cleanup-tasks"
```

Use the internal app address and the same `CRON_SECRET`. Check job logs for a successful response. Provider lifecycle rules remain a second layer for incomplete multipart state; see [Object storage](/platform/self-hosting/object-storage#configure-incomplete-multipart-cleanup).

## Verifying \[#verifying]

Create a workflow with a Schedule trigger set to every minute, deploy it, and watch the Logs view. An execution should appear within \~2 minutes. If nothing appears:

1. Check the scheduler's own logs — `docker compose -f docker-compose.prod.yml logs cron`, or `kubectl get cronjobs -n simstudio` for a recent `LAST SCHEDULE`.
2. Confirm the app and the scheduler share the same `CRON_SECRET`. A mismatch shows up as `401` in the scheduler log.
3. A `202` means the endpoint accepted the run; it does not confirm a schedule was due, so check the Logs view.

## Shipped job reference \[#shipped-job-reference]

| Job | Endpoint | Schedule | Drives |
| - | - | - | - |
| Schedule execution | `/api/schedules/execute` | `*/1 * * * *` | **Scheduled workflows** |
| Gmail poll | `/api/webhooks/poll/gmail` | `*/1 * * * *` | Gmail trigger |
| Outlook poll | `/api/webhooks/poll/outlook` | `*/1 * * * *` | Outlook trigger |
| IMAP poll | `/api/webhooks/poll/imap` | `*/1 * * * *` | IMAP trigger |
| RSS poll | `/api/webhooks/poll/rss` | `*/1 * * * *` | RSS trigger |
| Google Sheets poll | `/api/webhooks/poll/google-sheets` | `*/1 * * * *` | Sheets trigger |
| Google Drive poll | `/api/webhooks/poll/google-drive` | `*/1 * * * *` | Drive trigger |
| Google Calendar poll | `/api/webhooks/poll/google-calendar` | `*/1 * * * *` | Calendar trigger |
| HubSpot poll | `/api/webhooks/poll/hubspot` | `*/1 * * * *` | HubSpot trigger |
| Time pause/resume | `/api/resume/poll` | `*/1 * * * *` | Workflows paused on a timer |
| Outbox processing | `/api/webhooks/outbox/process` | `*/1 * * * *` | Transactional-outbox retries for billing, membership, enterprise issuance, and workflow-deployment side effects |
| Workspace file search dispatch | `/api/cron/workspace-file-search-dispatch` | `*/1 * * * *` | Dispatches indexing work for workspace file search |
| Knowledge projection | `/api/cron/knowledge-projection` | `*/1 * * * *` | Brings knowledge base search up to date with document, permission, and chunk changes |
| Table row-change fold | `/api/cron/fold-table-row-changes` | `*/1 * * * *` | Folds the table row-change log into each table's stored row count and version |
| Connector sync | `/api/knowledge/connectors/sync` | `*/5 * * * *` | Knowledge base connector syncs |
| Connector member sync | `/api/knowledge/connectors/member-sync` | `*/5 * * * *` | Per-member content/access sync for indexed connectors; live Search sources are skipped |
| Connector directory sync | `/api/knowledge/connectors/directory-sync` | `*/5 * * * *` | Refreshes stored groups for indexed administrator-mode connectors; live Search sources are skipped |
| Workspace events poll | `/api/workspace-events/poll` | `*/15 * * * *` | Workspace event triggers |
| Table row TTL cleanup | `/api/cron/cleanup-table-row-ttl` | `*/15 * * * *` | Deletes table rows whose TTL column has expired |
| Stale execution cleanup | `/api/cron/cleanup-stale-executions` | `*/30 * * * *` | Fails runs whose worker died without finishing — workflow executions, background jobs, table jobs and table runs — so they stop showing as running |
| OAuth token cleanup | `/api/cron/cleanup-oauth-tokens` | `0 * * * *` | Deletes access and refresh tokens after the retention tail |
| SCIM reconciliation | `/api/cron/scim-reconcile` | `17 * * * *` | Re-applies directory group mappings |
| Data drains | `/api/cron/run-data-drains` | `0 * * * *` | Enterprise data drains |
| Renew subscriptions | `/api/cron/renew-subscriptions` | `0 */12 * * *` | Renews Microsoft Teams chat subscriptions (Graph caps them at \~3 days) |
| Billing cycle close | `/api/cron/billing-cycle-close` | `0 */6 * * *` | Billing only — final overage collection and per-period tracker reset |
| Reconcile billing seats | `/api/cron/reconcile-billing-seats` | `0 * * * *` | Billing only — safe to disable when self-hosted |
| Reconcile inbox entitlement | `/api/cron/reconcile-inbox-entitlement` | `0 3 * * *` | Inbox access reconciliation |
| Cleanup sandbox images | `/api/cron/cleanup-sandbox-images` | `30 4 * * *` | Reclaims sandbox images |
| Cleanup session sandboxes | `/api/cron/cleanup-session-sandboxes` | `*/5 * * * *` | Kills chat workbenches paused past their retention window |
| Idempotency key cleanup | `/api/webhooks/cleanup/idempotency` | `0 5 * * *` | Deletes deduplication records older than seven days |

Subscription renewal covers Microsoft Teams chat triggers. Their Graph subscriptions last at most three days; rich notifications have a shorter limit. Gmail, Outlook, Drive, Calendar, and Sheets use the per-minute polling jobs instead. See [Microsoft Graph subscription lifetimes](https://learn.microsoft.com/en-us/graph/change-notifications-overview). SCIM reconciliation calls its authenticated endpoint directly each hour; it does not require Trigger.dev.

## Concurrency \[#concurrency]

On the default database-backed queue, scheduled execution capacity is shared across the deployment:

| Variable | Default | Applies to |
| - | - | - |
| `SCHEDULE_EXECUTION_CONCURRENCY_LIMIT` | `30` | Scheduled workflows in flight across the database-backed queue; also the schedule task limit on Trigger.dev |

Note:

`WORKFLOW_EXECUTION_CONCURRENCY_LIMIT`, `WEBHOOK_EXECUTION_CONCURRENCY_LIMIT`, and `RESUME_EXECUTION_CONCURRENCY_LIMIT` are `concurrencyLimit` settings on Trigger.dev task definitions. They have **no effect** unless `TRIGGER_DEV_ENABLED` is set, and neither the Helm chart nor Docker Compose configures Trigger.dev — so on a default self-host they are inert.

Adding replicas does not multiply the database queue's schedule cap. Raise it only after measuring memory and database headroom. The app orchestrates runs; local JavaScript uses Node child workers inside its container.

## Related docs \[#related-docs]

- [Observability](/platform/self-hosting/observability)
- [Upgrades](/platform/self-hosting/upgrades)
