ByteGuideHub
Distributed Systems / Backend Engineering • • 9 min read

Self-Hosted Durable Workflow Orchestration with Temporal: Saga Patterns, Idempotency, and Exactly-Once Side Effects

A practical guide to self-hosting Temporal for long-running business workflows: model saga compensations, enforce idempotency keys, and achieve exactly-once external side effects.

Sponsored / Ad Space

Self-Hosted Durable Workflow Orchestration with Temporal

Quick Summary (TL;DR)

  • Temporal gives you durable execution: workflow state survives crashes, restarts, and multi-day waits because it is rebuilt from an append-only event history.
  • Model long-running business processes as sagas—each forward step paired with a compensating action that runs in reverse order on failure.
  • Temporal guarantees at-least-once activity execution, not exactly-once. You must build idempotency into every external side effect.
  • Derive stable idempotency keys from workflow identity (never randomness), and wrap external writes in an outbox or transactional boundary.
  • Self-hosting requires a multi-node topology (frontend, history, matching, worker) plus a highly available database. Harden it before production.

Why Durable Execution Beats Hand-Rolled State Machines

Every team eventually writes a status column and a cron job to advance orders through PENDING → PAID → SHIPPED. It works until it doesn’t: a pod restarts mid-transition, a webhook arrives twice, and nobody knows whether the refund fired. Hand-rolled state machines encode what the state is but not how to safely resume.

Temporal flips the model. You write ordinary code—loops, conditionals, function calls—and the runtime persists every decision as an event. On restart, the workflow replays its history to reconstruct in-memory state, then continues from exactly where it stopped. You get retries, timeouts, and durability as platform primitives rather than bespoke glue.

The trade-off is discipline: workflow code must be deterministic, and every external call must be idempotent. This guide shows how to self-host the cluster and build those guarantees.

Self-Hosting Temporal: A Minimal Production Topology

flowchart TD
    W[Worker Fleet<br/>your workflow + activity code] -->|gRPC| F[Frontend<br/>x3, stateless]
    F --> H[History<br/>x3, sharded state]
    F --> M[Matching<br/>x3, task queues]
    H --> DB[(Persistence<br/>PostgreSQL / Cassandra)]
    M --> DB
    H --> V[(Visibility<br/>Elasticsearch)]
    UI[Web UI] --> F
    CLI[tctl] --> F

Four server roles matter: frontend (API gateway), history (owns workflow state and shards), matching (task queues), and worker (system background work). Your own application workers poll the frontend for tasks.

Docker Compose Baseline for Local Development

Start with the official dev server for iteration, then run the full stack via temporalio/docker-compose. A trimmed compose for local work:

services:
  temporal:
    image: temporalio/auto-setup:1.24
    environment:
      - DB=postgres12
      - DB_PORT=5432
      - POSTGRES_USER=temporal
      - POSTGRES_PWD=temporal
      - POSTGRES_SEEDS=postgresql
    ports: ["7233:7233"]
    depends_on: [postgresql]

  temporal-ui:
    image: temporalio/ui:2.27.0
    environment:
      - TEMPORAL_ADDRESS=temporal:7233
    ports: ["8080:8080"]

  postgresql:
    image: postgres:16
    environment:
      POSTGRES_USER: temporal
      POSTGRES_PASSWORD: temporal
    volumes: ["pgdata:/var/lib/postgresql/data"]

volumes: { pgdata: {} }

Hardening the Self-Hosted Cluster

  • Separate persistence from the cluster. Use managed PostgreSQL with synchronous replication or a Cassandra ring; never co-locate the database on the same nodes.
  • Run three instances of each server role across availability zones and front them with a load balancer.
  • Enable mTLS between frontend and history/matching, and between workers and frontend.
  • Size history shards deliberately (default 512); you cannot easily change this later.
  • Back up the visibility store (Elasticsearch) and the primary database on independent schedules.

Workflow Design Fundamentals: Determinism and Versioning

Workflow code is replayed from history, so it must produce identical decisions given identical inputs. That rules out time.Now(), math/rand, direct network calls, and map iteration order. Use workflow.Now(), workflow.SideEffect, and activities for anything non-deterministic.

When you change workflow logic, version it so in-flight executions replay against their original history:

v := workflow.GetVersion(ctx, "add-fraud-check", workflow.DefaultVersion, 1)
if v == 1 {
    err = workflow.ExecuteActivity(ctx, CheckFraud, order).Get(ctx, nil)
}

Modeling Sagas: Compensating Transactions That Actually Roll Back

A saga is a sequence of local transactions, each with a compensating action. If step N fails, run compensations for steps N-1 … 1 in reverse. This trades atomicity for availability—the standard pattern for multi-service business processes.

A Payment and Fulfillment Saga in Go

func OrderSaga(ctx workflow.Context, order Order) error {
    ao := workflow.ActivityOptions{
        StartToCloseTimeout: 10 * time.Second,
        RetryPolicy: &temporal.RetryPolicy{MaximumAttempts: 5},
    }
    ctx = workflow.WithActivityOptions(ctx, ao)

    var compensations []func(workflow.Context) error
    compensate := func() {
        for i := len(compensations) - 1; i >= 0; i-- {
            _ = compensations[i](ctx) // best-effort, idempotent
        }
    }

    if err := workflow.ExecuteActivity(ctx, ReserveInventory, order).Get(ctx, nil); err != nil {
        return err
    }
    compensations = append(compensations, func(c workflow.Context) error {
        return workflow.ExecuteActivity(c, ReleaseInventory, order).Get(c, nil)
    })

    if err := workflow.ExecuteActivity(ctx, ChargePayment, order).Get(ctx, nil); err != nil {
        compensate()
        return err
    }
    compensations = append(compensations, func(c workflow.Context) error {
        return workflow.ExecuteActivity(c, RefundPayment, order).Get(c, nil)
    })

    if err := workflow.ExecuteActivity(ctx, ShipOrder, order).Get(ctx, nil); err != nil {
        compensate()
        return err
    }
    return nil
}

Compensation Semantics and Failure Modes

Compensations must themselves be idempotent and retried with a bounded policy. If a compensation fails permanently, never swallow it—record it in workflow state, emit a metric, and route to a manual-intervention state. Some actions are irreversible (an email sent, a package shipped), so treat those as best-effort with explicit business acknowledgment.

Idempotency: The Contract Between Workflow and External System

Deriving Stable Idempotency Keys

Derive keys deterministically from workflow identity and business intent—never from randomness or timestamps:

func IdempotencyKey(ctx workflow.Context, step, entityID string) string {
    info := workflow.GetInfo(ctx)
    raw := info.WorkflowExecution.ID + ":" + step + ":" + entityID
    return fmt.Sprintf("%x", sha256.Sum256([]byte(raw)))
}

Because the key is stable across retries, the downstream service can deduplicate. Generating the key inside the activity with uuid.New() defeats the purpose—retries would produce new keys.

Idempotency at the Activity Boundary

Pass the key as an Idempotency-Key header. On the server side, store (key → response) in a unique-constrained table; a duplicate request returns the stored response instead of re-executing.

func ChargePayment(ctx context.Context, order Order) error {
    key := order.IdempotencyKey // computed in workflow, stored on the DTO
    return gateway.Charge(ctx, ChargeRequest{
        IdempotencyKey: key,
        Amount:         order.Total,
    })
}

Exactly-Once Side Effects: What Temporal Guarantees and What You Must Build

Temporal guarantees the workflow advances exactly once in terms of its history. Activities, however, are at-least-once: a timeout may trigger a retry even if the first attempt actually succeeded. Exactly-once external effects are your responsibility.

The Outbox Pattern Inside an Activity

Write the side effect and a record of it in the same local transaction, then publish asynchronously:

sequenceDiagram
    participant W as Workflow
    participant A as Activity
    participant DB as Local DB
    participant Q as Message Broker
    W->>A: ExecuteActivity(Charge)
    A->>DB: INSERT charge + outbox row (one tx)
    A-->>W: success
    DB->>Q: relay publishes outbox row
    Q->>Consumer: deliver (idempotent by key)

If the activity crashes before commit, nothing happened. If it crashes after commit, the retry hits the unique constraint and returns the prior result. Either way, the external effect happens once.

Reconciling Ambiguous Outcomes

When an activity times out, you don’t know if the downstream call succeeded. Build a reconciliation activity that queries the external system by idempotency key and resolves the true state before retrying. Schedule it as a separate step rather than relying on blind retries.

Comparison: Orchestration Approaches for Long-Running Processes

ApproachDurabilityRetries / TimeoutsSaga SupportOperational CostBest For
Hand-rolled state machine + cronManualCustom codeManualLow initially, high over timeTiny, single-service flows
Queue + worker (SQS/RabbitMQ)Message-levelBasicNone built-inMediumShort, fire-and-forget tasks
Temporal (self-hosted)Full event historyFirst-classNative via codeMedium–high (cluster ops)Long-running, multi-step business flows
Temporal CloudFullFirst-classNativeLow ops, usage-based costTeams avoiding cluster ops
2PC / distributed txStrong atomicityN/AN/AHigh, brittleShort, homogeneous resources

Operational Checklist Before Going Live

  • Workflow code passes determinism review (no time.Now, no random, no direct I/O).
  • Every external call carries a stable idempotency key.
  • Compensations are idempotent, reversed, and bounded-retry.
  • Permanent compensation failures alert and route to manual review.
  • Cluster runs ≥3 instances per role across AZs with mTLS.
  • Database and visibility store backed up independently.
  • Saga tests force failure at each step and assert compensation order.
  • Dashboards track activity retries, saga rollbacks, and stuck workflows.

FAQ

Does Temporal give me exactly-once execution of activities? No. Temporal guarantees at-least-once activity execution: an activity may be retried after a timeout even if the first attempt succeeded. Exactly-once side effects come from making each activity idempotent or wrapping the external call in an outbox. The workflow itself advances exactly once in terms of its history, but external effects still need idempotency keys.

How do I choose an idempotency key for an external API call? Derive it deterministically from workflow ID, run ID, activity type, and a logical step name—for example sha256(workflowID + ':' + step + ':' + entityID). Pass it as an Idempotency-Key header. Because it is stable across retries, the downstream service deduplicates. Never generate the key inside the activity with randomness.

What is the difference between a saga and a distributed transaction? A distributed transaction (two-phase commit) provides atomicity but requires locking and coordinated participants—impractical across heterogeneous services and long durations. A saga is a sequence of local transactions with compensating actions; on failure, completed steps are undone by compensations. Sagas trade atomicity for availability.

Should compensations run in reverse order, and what if one fails? Yes—reverse order of forward steps. Compensations must be idempotent and retried with a bounded policy. If one fails permanently, record it in workflow state, alert, and route to manual intervention. Some compensations are irreversible (an email already sent), so design them as best-effort with explicit business acknowledgment.

How do I handle non-deterministic code in workflows? Avoid random numbers, wall-clock time, direct I/O, and map iteration order in workflow code. Use workflow.Now(), workflow.SideEffect, and activities for non-determinism. When changing logic, use versioning APIs (workflow.GetVersion in Go, patched in TypeScript) so in-flight executions replay correctly against their original history.

Sponsored / Ad Space