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.
Overview
Section titled “Overview”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.
How it works
Section titled “How it works”Writing state
Section titled “Writing state”State is written in two ways:
File-based state
Section titled “File-based state”Write files to the /state-out directory (or $STATE_OUT_DIR environment variable):
#!/bin/bash# Renew SSL certificateopenssl 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 jobcp /etc/letsencrypt/live/example.com/fullchain.pem $STATE_OUT_DIR/cert.pemcp /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.
Structured state (STATE: protocol)
Section titled “Structured state (STATE: protocol)”Emit JSON to stdout using the STATE: prefix to store structured data:
#!/bin/bash# Check certificate expiry and emit stateDAYS=$(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-mergedMultiple STATE: lines in the same step are automatically merged (last write wins for duplicate keys):
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.
Reading state
Section titled “Reading state”Previous files
Section titled “Previous files”Access the previous state directory via the /state mount (or $STATE_DIR environment variable):
#!/bin/bash# Check if a certificate exists from a previous runif [ -f "$STATE_DIR/cert.pem" ]; then echo "Found previous certificate, checking expiry..." openssl x509 -in "$STATE_DIR/cert.pem" -noout -enddateelse echo "No previous certificate found"fiThe /state directory is read-only and contains the contents of the latest snapshot.
In templates
Section titled “In templates”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 behavior
Section titled “Runner behavior”| 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.
Multi-step tasks
Section titled “Multi-step tasks”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-outor emitSTATE: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:
load-cursorruns first, reads previous cursor from/state, emits new cursor viaSTATE:process-batchis claimed next and sees the updated state (step 1’sSTATE:JSON)save-cursoralso sees the updated state
Configuration
Section titled “Configuration”State storage uses the same archive backend as log storage by default. Override with a dedicated section:
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.
Archive key format
Section titled “Archive key format”State snapshots are stored with this key structure:
{prefix}{workspace}/{task_name}/{job_id}.tar.gzExample: state/production/renew-ssl/550e8400-e29b-41d4-a716-446655440000.tar.gz
Environment variables
Section titled “Environment variables”| 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.
Retention
Section titled “Retention”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_idreference is cleared when the job is deleted, but the snapshot remains in the archive
Which state a step sees
Section titled “Which state a step sees”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.
Pinned jobs
Section titled “Pinned jobs”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.
Use cases
Section titled “Use cases”SSL certificate renewal
Section titled “SSL certificate renewal”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 }}"Incremental processing with cursors
Section titled “Incremental processing with cursors”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-logsBuild artifact caching
Section titled “Build artifact caching”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: buildGlobal workspace state
Section titled “Global workspace state”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.
Writing global state
Section titled “Writing global state”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 workspaceecho "GLOBAL_STATE: {\"api_token\": \"$(vault read -field=token secret/api)\", \"refreshed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"Reading global state
Section titled “Reading global state”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 }}"How it differs from task state
Section titled “How it differs from task state”| 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.
Uploading state manually
Section titled “Uploading state manually”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.
Endpoints
Section titled “Endpoints”POST /api/workspaces/{ws}/tasks/{task}/state # task-statePOST /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
Runpermission on the task (same permission as triggering it via/execute). - Global-state: requires admin.
Query parameters
Section titled “Query parameters”| 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.jsonsynthesised 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.
The tarball
Section titled “The tarball”- Include any files you want available at
$STATE_DIRon the next run. - Do not include a root-level
state.json— pass state values via query parameters instead. Nested paths likesubdir/state.jsonare allowed. - An empty tarball is valid; an empty body (Content-Length 0) is also valid and is treated as an empty tarball.
Examples
Section titled “Examples”Bootstrap a Let’s Encrypt certificate for a renewal task:
stroem-api state upload \ --workspace production \ --task renew-ssl \ --state domain=example.com \ --state expiry=2026-07-21 \ fullchain.pem privkey.pemRotate only the expiry value (merge mode, no file changes):
stroem-api state upload \ --workspace production \ --task renew-ssl \ --mode merge \ --state expiry=2027-07-21Raw curl:
tar -czf /tmp/certs.tar.gz fullchain.pem privkey.pemcurl -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.
Caveats
Section titled “Caveats”- Values are strings — use
| int/| floatin templates if type coercion matters.| intand| floatraise an error on input that does not parse as a number (they no longer return0). There is noboolfilter; 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.
Limitations
Section titled “Limitations”- 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 theSTATE: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.