Skip to content

Task State Snapshots

Tasks in Strøm can persist both files and structured data between runs using state snapshots. This enables workflows like SSL certificate renewal, incremental processing with cursors, and caching build artifacts.

State snapshots are immutable archives created after a step completes, containing:

  • Files: Any artifacts written to /state-out (certificates, keys, compiled binaries, caches)
  • Structured data: JSON objects emitted via the STATE: protocol (counters, cursors, metadata, timestamps)

Each task maintains a rolling history of state snapshots. When a new job starts, all steps have access to the latest snapshot from the previous run.

State is written in two ways:

Write files to the /state-out directory (or $STATE_OUT_DIR environment variable):

#!/bin/bash
# Renew SSL certificate
openssl req -new -x509 -days 365 -nodes \
-out /etc/letsencrypt/live/example.com/fullchain.pem \
-keyout /etc/letsencrypt/live/example.com/privkey.pem
# Persist the certificate for the next job
cp /etc/letsencrypt/live/example.com/fullchain.pem $STATE_OUT_DIR/cert.pem
cp /etc/letsencrypt/live/example.com/privkey.pem $STATE_OUT_DIR/privkey.pem
echo "Certificate renewed"

Files are automatically collected into a gzip tarball and stored after the step completes.

Emit JSON to stdout using the STATE: prefix to store structured data:

#!/bin/bash
# Check certificate expiry and emit state
DAYS=$(openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem \
-noout -enddate | cut -d= -f2)
echo "STATE: {\"expiry_date\": \"$DAYS\", \"checked_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
echo "STATE: {\"domain\": \"example.com\"}" # Multiple lines are deep-merged

Multiple STATE: lines in the same step are automatically merged (last write wins for duplicate keys):

Terminal window
echo "STATE: {\"cursor\": \"page-1\", \"total_pages\": 50}"
echo "STATE: {\"cursor\": \"page-2\"}" # Overwrites cursor, keeps total_pages
# Result: {"cursor": "page-2", "total_pages": 50}

Both approaches work together — files and structured state are stored in the same snapshot. File state becomes state.json in the tarball.

Access the previous state directory via the /state mount (or $STATE_DIR environment variable):

#!/bin/bash
# Check if a certificate exists from a previous run
if [ -f "$STATE_DIR/cert.pem" ]; then
echo "Found previous certificate, checking expiry..."
openssl x509 -in "$STATE_DIR/cert.pem" -noout -enddate
else
echo "No previous certificate found"
fi

The /state directory is read-only and contains the contents of the latest snapshot.

Structured state is available in Tera templates as the state object. Use it to conditionally execute steps:

tasks:
renew-cert:
flow:
check-expiry:
action: check-ssl-expiry
input:
domain: "{{ input.domain }}"
renew-cert:
action: renew-ssl
depends_on: [check-expiry]
# Only run if no previous state, or less than 30 days remain
when: "{{ not state or state.days_remaining < 30 }}"
input:
domain: "{{ input.domain }}"
upload-cert:
action: upload-to-cdn
depends_on: [renew-cert]
input:
domain: "{{ state.domain | default(value='example.com') }}"

If no previous state exists, state is null in the template context — use not state to guard operations, or state.key | default(value=…), which falls back to the default on a first run.

Runner /state (read) /state-out (write) STATE: protocol
Shell (local) $STATE_DIR path $STATE_OUT_DIR path Yes
Docker Bind mount :ro at /state Bind mount :rw at /state-out Yes
Kubernetes emptyDir volume emptyDir volume Yes

All runners set STATE_DIR and STATE_OUT_DIR environment variables automatically.

When a task has multiple steps:

  • All steps start with the same previous state snapshot
  • Sequential steps see state written by earlier steps (state is resolved at claim time)
  • Parallel steps all see the pre-parallel state independently; each can write new state
  • Only steps that write to /state-out or emit STATE: lines create a new snapshot

Example: Sequential state propagation

tasks:
incremental-process:
flow:
load-cursor:
action: fetch-cursor # Reads $STATE_DIR/cursor.json, emits STATE:
process-batch:
action: process-data # Reads $STATE_DIR/cursor.json (updated by step 1)
depends_on: [load-cursor] # Sees step 1's state
save-cursor:
action: save-cursor # Writes new cursor to $STATE_OUT_DIR
depends_on: [process-batch]

In this flow:

  1. load-cursor runs first, reads previous cursor from /state, emits new cursor via STATE:
  2. process-batch is claimed next and sees the updated state (step 1’s STATE: JSON)
  3. save-cursor also sees the updated state

State storage uses the same archive backend as log storage by default. Override with a dedicated section:

server-config.yaml
state_storage:
prefix: "state/" # archive key prefix (default: "state/")
max_snapshots: 5 # snapshots retained per task (default: 5)
# Optional: override the archive backend
# archive:
# type: s3
# bucket: my-state-bucket
# region: eu-west-1
# endpoint: "https://s3.example.com" # optional (for S3-compatible services)

If neither state_storage nor log_storage.archive is configured, the worker cannot download or upload snapshots — state directories remain empty, no new snapshots are stored, and the claim response carries no snapshot keys. Templates still resolve {{ state.* }} / {{ global_state.* }} from snapshot rows already recorded: migration 047 persists the parsed sidecar on the row itself, so rendering reads it straight from the database rather than the archive.

State snapshots are stored with this key structure:

{prefix}{workspace}/{task_name}/{job_id}.tar.gz

Example: state/production/renew-ssl/550e8400-e29b-41d4-a716-446655440000.tar.gz

Variable Value Availability
STATE_DIR Path to the extracted previous state (read-only) All runners
STATE_OUT_DIR Path for writing new state All runners

These are set automatically by the worker and runner. If no previous state exists, STATE_DIR points to an empty directory.

Snapshots are pruned automatically per task to keep a rolling window of recent snapshots:

  • Default: keep the last 5 snapshots per task (configurable via max_snapshots) — per task and per ref, see Pinned jobs
  • Older snapshots are deleted from both the database and archive
  • Snapshots survive job retention cleanup — the snapshot’s job_id reference is cleared when the job is deleted, but the snapshot remains in the archive

State belongs to the job: every step of a job reads and writes the state of the job’s own workspace and task. That includes a step that runs a cross-workspace action: it reads and writes the calling job’s state, not the action owner’s. The server takes these coordinates from the job, never from the worker’s request.

A job running on a git ref reads and writes state partitioned by the ref string: release/2.3’s snapshots are separate from release/2.4’s and from the default branch’s. Bumping a ref (v2.3.1 → v2.3.2) starts with empty state. The same holds for global state: a pinned job’s GLOBAL_STATE: and {{ global_state.* }} use that workspace’s partition for the ref.

Workers older than the server do not tell the server which job a state download is for, so a pinned job claimed by such a worker gets the default (unpinned) partition mounted at /state. Its templates ({{ state.* }}) and its uploads still use the right partition, because the server renders templates and decides where uploads go. Upgrade workers together with the server.

actions:
check-ssl:
type: script
script: |
#!/bin/bash
if [ -f "$STATE_DIR/cert.pem" ]; then
EXPIRY=$(openssl x509 -in "$STATE_DIR/cert.pem" -noout -enddate | cut -d= -f2)
DAYS=$(( ($(date -d "$EXPIRY" +%s) - $(date +%s)) / 86400 ))
echo "STATE: {\"days_remaining\": $DAYS, \"expiry\": \"$EXPIRY\"}"
else
echo "STATE: {\"days_remaining\": 0, \"expiry\": null}"
fi
renew-ssl:
type: script
script: |
#!/bin/bash
# Use certbot or acme.sh to renew
certbot renew
cp /etc/letsencrypt/live/example.com/fullchain.pem $STATE_OUT_DIR/cert.pem
cp /etc/letsencrypt/live/example.com/privkey.pem $STATE_OUT_DIR/privkey.pem
tasks:
maintain-cert:
flow:
check:
action: check-ssl
renew:
action: renew-ssl
depends_on: [check]
when: "{{ not state or state.days_remaining < 30 }}"
actions:
process-logs:
type: script
script: |
#!/bin/bash
# Load cursor from previous state
CURSOR=$(cat "$STATE_DIR/cursor.json" 2>/dev/null | jq -r .offset // "0")
# Process logs starting from cursor
tail -n +$CURSOR /var/log/app.log | while read line; do
echo "$line" | process-line
done
# Save new cursor position
NEW_CURSOR=$(wc -l < /var/log/app.log)
echo "STATE: {\"offset\": $NEW_CURSOR, \"last_processed\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
tasks:
log-processor:
flow:
ingest:
action: process-logs
actions:
build:
type: script
runner: docker
language: javascript
dependencies: [next]
script: |
cd /workspace
# Restore cache from previous build
if [ -d "$STATE_DIR/.next/cache" ]; then
cp -r "$STATE_DIR/.next/cache" .next/
fi
# Build and cache artifacts
npm run build
mkdir -p $STATE_OUT_DIR/.next
cp -r .next/cache $STATE_OUT_DIR/.next/
echo "STATE: {\"build_time\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"size_mb\": $(du -sm .next | cut -f1)}"
tasks:
web-build:
flow:
build:
action: build

In addition to per-task state, Strøm supports global workspace state — shared across all tasks in a workspace. Any task can read and write it.

Use the GLOBAL_STATE: protocol or write files to /global-state-out ($GLOBAL_STATE_OUT_DIR):

#!/bin/bash
# Share a token across all tasks in this workspace
echo "GLOBAL_STATE: {\"api_token\": \"$(vault read -field=token secret/api)\", \"refreshed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"

Previous global state is available at /global-state ($GLOBAL_STATE_DIR) and in Tera templates as {{ global_state.* }}:

tasks:
deploy:
flow:
deploy:
action: deploy-app
input:
token: "{{ global_state.api_token }}"
Task state Global state
Scope Per task Per workspace (all tasks)
Protocol STATE: GLOBAL_STATE:
Read dir /state /global-state
Write dir /state-out /global-state-out
Templates {{ state.* }} {{ global_state.* }}
Concurrent writes Per-task (rare) Cross-task (last writer wins)

Both types of state are available simultaneously — a step can read and write both task state and global state in the same run.

State snapshots are normally written by tasks, but operators can also upload a snapshot out-of-band via the API. This is useful for bootstrapping — for example, seeding a Let’s Encrypt certificate so a renewal task has something to read on first run.

POST /api/workspaces/{ws}/tasks/{task}/state # task-state
POST /api/workspaces/{ws}/state # global workspace state (admin)

Both accept a gzip tarball (max 50 MB) in the request body and state-JSON key/value pairs as query parameters.

Manual uploads always write the default (unpinned) partition; there is no way yet to upload state for a git ref’s partition.

  • Task-state: requires Run permission on the task (same permission as triggering it via /execute).
  • Global-state: requires admin.
Parameter Default Meaning
mode replace replace = snapshot contains exactly what you upload; merge = overlay on top of the latest snapshot
any other key=value — becomes a top-level string entry in state.json
  • Replace (default): the new snapshot’s files are exactly the tarball’s contents plus state.json synthesised from query params. Any file or key present in the previous snapshot and absent from the upload is dropped.
  • Merge: the new snapshot starts from the previous snapshot’s contents, then uploaded files overlay same-named files (new wins), and query-param state keys shallow-merge over the previous state.json (new wins).

Merge cannot delete files or state keys — to remove something, use mode=replace and omit it.

  • Include any files you want available at $STATE_DIR on the next run.
  • Do not include a root-level state.json — pass state values via query parameters instead. Nested paths like subdir/state.json are allowed.
  • An empty tarball is valid; an empty body (Content-Length 0) is also valid and is treated as an empty tarball.

Bootstrap a Let’s Encrypt certificate for a renewal task:

Terminal window
stroem-api state upload \
--workspace production \
--task renew-ssl \
--state domain=example.com \
--state expiry=2026-07-21 \
fullchain.pem privkey.pem

Rotate only the expiry value (merge mode, no file changes):

Terminal window
stroem-api state upload \
--workspace production \
--task renew-ssl \
--mode merge \
--state expiry=2027-07-21

Raw curl:

Terminal window
tar -czf /tmp/certs.tar.gz fullchain.pem privkey.pem
curl -X POST \
-H "Authorization: Bearer $STROEM_TOKEN" \
-H "Content-Type: application/gzip" \
--data-binary @/tmp/certs.tar.gz \
"$STROEM_URL/api/workspaces/production/tasks/renew-ssl/state?domain=example.com&expiry=2026-07-21"

Each upload creates a synthetic job row with source_type="upload" and status="completed". Filter the jobs list with ?source_type=upload to see upload history. The job’s input JSON records the uploader, size, and SHA-256 of the stored tarball.

  • Values are strings — use | int / | float in templates if type coercion matters. | int and | float raise an error on input that does not parse as a number (they no longer return 0). There is no bool filter; compare with == 'true' instead.
  • Total query-string size is limited by the web server (~8 KB typical). For larger state, emit via the STATE: protocol from a task run.
  • Do not put secrets in query parameters — they land in access logs and tracing spans. Use the secret backend (see Secrets & Encryption) instead.
  • To remove files or state keys, use mode=replace.
  • State is immutable once a snapshot is created. To update state, emit new STATE:/GLOBAL_STATE: lines or write new files from the step.
  • The maximum size of a state snapshot is 50 MB. Oversized uploads are rejected with a warning (non-fatal — the job continues).
  • For Kubernetes runners, file-based state (/state-out) works via the STATE: protocol. Direct file uploads are supported on shell and Docker runners.
  • Global state concurrent writes don’t merge — last writer wins. Coordinate via task dependencies if ordering matters.