← Back to list

Getting Started with Temporal.io: Docker/Podman Setup and Your First Java Workflow

If you’ve ever built a distributed system, you know the pain: retries that don’t retry, state that gets lost, and failure recovery code…

Alexander Machekhin · 2026-02-10 04:15 · 1 claps · 18.7 min read
#java #temporalio #reliability #distributed-systems #workflow
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Getting Started with Temporal.io: Docker/Podman Setup and Your First Java Workflow

If you’ve ever built a distributed system, you know the pain: retries that don’t retry, state that gets lost, and failure recovery code that’s more complex than your actual business logic. Temporal.io changes the game by letting you write fault-tolerant workflows as plain code — no more hand-rolling state machines or fighting with message queues.

In this post, I’ll walk you through spinning up a Temporal server in Docker and building a complete Java application that defines a workflow, an activity, a worker, and a client to kick it all off.

What Is Temporal?

Temporal is an open-source, durable execution platform. It guarantees that your workflow code runs to completion, even in the face of process crashes, network outages, or server restarts. You write workflows as ordinary code — with loops, conditionals, and function calls — and Temporal handles the persistence, retries, and state management behind the scenes.

The core concepts you need to know:

  • Workflow — a reliable, long-running function that orchestrates your business logic. Workflows must be deterministic.
  • Activity — a single unit of work (an API call, a database write, a file upload). Activities handle all non-deterministic or side-effecting operations and are automatically retried on failure.
  • Worker — a process that hosts and executes your workflow and activity code. Workers poll the Temporal server for tasks.
  • Task Queue — a named queue that connects clients, workers, and the Temporal server. Workflows and activities are dispatched through task queues.

Step 1: Running Temporal in Docker/Podman

Option A: Single Docker Command (Quickest Start)

podman run --rm -p 7233:7233 -p 8233:8233 temporalio/temporal server start-dev --ip 0.0.0.0

That’s it. This single command gives you:

  • Temporal Server with the default namespace already registered
  • Temporal Web UI accessible at http://localhost:8233
  • In-memory SQLite database (data is lost when the container stops)
  • gRPC frontend on localhost:7233 — this is the endpoint your Java application will connect to

If you want your workflow data to persist across restarts, mount a volume for the database file:

podman run --rm \
  -p 7233:7233 -p 8233:8233 \
  -v temporal-data:/data \
  temporalio/temporal server start-dev \
  --ip 0.0.0.0 \
  --db-filename /data/temporal.db

Option B: Install Temporal CLI Locally

# macOS
brew install temporal

# Linux (download and extract)
curl -sSf https://temporal.download/cli/archive/latest?platform=linux&arch=amd64 -o temporal.tar.gz
tar xzf temporal.tar.gz
sudo mv temporal /usr/local/bin/

Then start the development server:

temporal server start-dev --db-filename temporal.db

The server will be available at localhost:7233 and the UI at [http://localhost:8233.](http://localhost:8233.)

Option C: Docker/Podman Compose (Production-like Setup)

Great set of docker/podman compose is in temporal github: https://github.com/temporalio/samples-server/tree/main/compose

Verify It’s Running

Open the Web UI in your browser — http://localhost:8233 for Options A/B, or http://localhost:8080 for Option C. You should see the Temporal dashboard with the default namespace registered.

You can also interact with the server using the Temporal CLI:

temporal workflow list
temporal namespace describe default

Step 2: Setting Up the Java Project

We’ll use Maven for dependency management. Create a new Maven project and add the Temporal Java SDK to your pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>dev.alexmachekhin</groupId>
    <artifactId>temporalio-java</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>temporalio-java</name>
    <description>temporalio-java</description>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>
    <properties>
        <java.version>25</java.version>
        <temporal.version>1.32.1</temporal.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>io.temporal</groupId>
            <artifactId>temporal-sdk</artifactId>
            <version>${temporal.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

There is Spring Web dependency but it is not required for Temporal. It is for creating web service which can be used to create workflows.

Step 3: Define the Activity

Activities are where your “real work” happens — API calls, database queries, file I/O, and anything else that interacts with the outside world. In Temporal’s Java SDK, you define an Activity as an interface annotated with @ActivityInterface, then provide an implementation.

package dev.alexmachekhin.temporalio.activities;

import io.temporal.activity.ActivityInterface;
import io.temporal.activity.ActivityMethod;

@ActivityInterface
public interface RandomNumberActivities {

    @ActivityMethod
    int getRandomNumber(int min, int max);

}

Activity Implementation

package dev.alexmachekhin.temporalio.activities;

import java.util.Random;

public class RandomNumberActivitiesImpl implements RandomNumberActivities {

    private final Random random = new Random();

    @Override
    public int getRandomNumber(int min, int max) {
        if (new Random().nextInt(100) > 1) {
            throw new RuntimeException("Random number is wrong");
        }
        return min + random.nextInt(max - min + 1);
    }
}

The key insight: if getRandomNumber fails (say, because an external API is down), Temporal will automatically retry it according to the retry policy you configure. Your workflow doesn't need to care about transient failures.

Step 4: Define the Workflow

Workflows orchestrate the activities. They’re defined as interfaces annotated with @WorkflowInterface, with the entry-point method marked with @WorkflowMethod.

It is possible to use @WorkflowInterfaceand@WorkflowMethodin the same interface or just define method in one interface and then create another interface which extends it and marked as workflow interface. If you want to have several implementations then you will need separate interfaces which are marked as @WorkflowInterface

Workflow Interface

package dev.alexmachekhin.temporalio.workflow;

import io.temporal.workflow.WorkflowMethod;

public interface RandomNumber {

    @WorkflowMethod
    int getRandomNumber(int min, int max);
}
package dev.alexmachekhin.temporalio.workflow;

import io.temporal.workflow.WorkflowInterface;

@WorkflowInterface
public interface RandomNumberWorkFlow extends RandomNumber {

}
package dev.alexmachekhin.temporalio.workflow;

import io.temporal.workflow.WorkflowInterface;

@WorkflowInterface
public interface RandomNumberAsyncWorkFlow extends RandomNumber {

}

Workflow Implementation

Sync implementation

package dev.alexmachekhin.temporalio.workflow;

import dev.alexmachekhin.temporalio.activities.RandomNumberActivities;
import io.temporal.activity.ActivityOptions;
import io.temporal.common.RetryOptions;
import io.temporal.workflow.Workflow;

import java.time.Duration;

public class RandomNumberWorkFlowImpl implements RandomNumberWorkFlow {

    private final RandomNumberActivities activities =
            Workflow.newActivityStub(
                    RandomNumberActivities.class,
                    ActivityOptions.newBuilder()
                            .setStartToCloseTimeout(Duration.ofSeconds(1))
                            .setRetryOptions(
                                    RetryOptions.newBuilder()
                                            .setInitialInterval(Duration.ofMillis(10))
                                            .setMaximumInterval(Duration.ofMillis(10))
                                            .setBackoffCoefficient(1)
                                            .build()
                            )
                            .build()
            );

    @Override
    public int getRandomNumber(int min, int max) {
        int n1 = activities.getRandomNumber(min, max);
        int n2 = activities.getRandomNumber(min, max);

        return n1 + n2;
    }
}

Async implementation (running activities in parallel)

package dev.alexmachekhin.temporalio.workflow;

import dev.alexmachekhin.temporalio.activities.RandomNumberActivities;
import io.temporal.activity.ActivityOptions;
import io.temporal.common.RetryOptions;
import io.temporal.workflow.Async;
import io.temporal.workflow.Promise;
import io.temporal.workflow.Workflow;

import java.time.Duration;

public class RandomNumberWorkFlowAsyncImpl implements RandomNumberAsyncWorkFlow {

    private final RandomNumberActivities activities =
            Workflow.newActivityStub(
                    RandomNumberActivities.class,
                    ActivityOptions.newBuilder()
                            .setStartToCloseTimeout(Duration.ofSeconds(1))
                            .setRetryOptions(
                                    RetryOptions.newBuilder()
                                            .setInitialInterval(Duration.ofMillis(10))
                                            .setMaximumInterval(Duration.ofMillis(10))
                                            .setBackoffCoefficient(1)
                                            .build()
                            )
                            .build()
            );

    @Override
    public int getRandomNumber(int min, int max) {
        Promise<Integer> n1 = Async.function(
                activities::getRandomNumber, min, max
        );

        Promise<Integer> n2 = Async.function(
                activities::getRandomNumber, min, max
        );

        return n1.get() + n2.get();
    }
}

A few things to note:

  • Workflow.newActivityStub() creates a proxy that, when called, schedules the activity for execution on the Temporal server rather than running it in-process.
  • setStartToCloseTimeout defines how long a single activity execution can take before it's considered failed.
  • The workflow code itself is deterministic. Temporal replays it from the event history to reconstruct state, so you should never use Thread.sleep(), System.currentTimeMillis(), or random number generators directly. Use Workflow.sleep() and Workflow.sideEffect() instead.

Step 5: Create the Worker

Workers are the processes that actually execute your workflow and activity code. They connect to the Temporal server, poll a task queue, and process tasks as they arrive.

package dev.alexmachekhin.temporalio.worker;

import dev.alexmachekhin.temporalio.activities.RandomNumberActivitiesImpl;
import dev.alexmachekhin.temporalio.workflow.RandomNumberWorkFlowAsyncImpl;
import dev.alexmachekhin.temporalio.workflow.RandomNumberWorkFlowImpl;
import io.temporal.client.WorkflowClient;
import io.temporal.serviceclient.WorkflowServiceStubs;
import io.temporal.serviceclient.WorkflowServiceStubsOptions;
import io.temporal.worker.Worker;
import io.temporal.worker.WorkerFactory;
import io.temporal.worker.WorkerOptions;

public class RandomNumberWorker {

    public RandomNumberWorker(String serverUrl) {
        WorkflowServiceStubs service = WorkflowServiceStubs.newServiceStubs(
                WorkflowServiceStubsOptions.newBuilder()
                        .setTarget(serverUrl)
                        .build()
        );
        WorkflowClient client = WorkflowClient.newInstance(service);

        // Create a Worker Factory and a Worker that listens on a Task Queue
        WorkerFactory factory = WorkerFactory.newInstance(client);
        Worker worker = factory.newWorker("random-number-task-queue",
                WorkerOptions.newBuilder()
                        .setMaxConcurrentActivityExecutionSize(100)  // Parallel activities
                        .setMaxConcurrentWorkflowTaskExecutionSize(50)
                        .build());

        // Register the Workflow and Activity implementations
        worker.registerWorkflowImplementationTypes(RandomNumberWorkFlowImpl.class, RandomNumberWorkFlowAsyncImpl.class);
        worker.registerActivitiesImplementations(new RandomNumberActivitiesImpl());

        // Start listening for tasks
        factory.start();
    }
}

WorkflowServiceStubs.newLocalServiceStubs() connects to localhost:7233 by default — exactly where our Docker Compose setup exposes the Temporal frontend.

Step 6: Build the Client (Starter)

The client is what triggers workflow execution. This is the entry point of your application — an API endpoint, a scheduled job, or a CLI command.

package dev.alexmachekhin.temporalio;

import dev.alexmachekhin.temporalio.workflow.RandomNumberAsyncWorkFlow;
import dev.alexmachekhin.temporalio.workflow.RandomNumberWorkFlow;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import io.temporal.common.RetryOptions;
import io.temporal.serviceclient.WorkflowServiceStubs;
import io.temporal.serviceclient.WorkflowServiceStubsOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.Duration;

@RestController
@RequestMapping("/random")
public class RandomNumberController {

    private final String temporalServer;

    public RandomNumberController(@Value("${app.config.temporalServer}") String temporalServer) {
        this.temporalServer = temporalServer;
    }

    @GetMapping
    public int getRandomNumber(@RequestParam int min, @RequestParam int max) {
        // Connect to Temporal
        WorkflowServiceStubs service = WorkflowServiceStubs.newServiceStubs(
                WorkflowServiceStubsOptions.newBuilder()
                        .setTarget(temporalServer)
                        .build()
        );
        WorkflowClient client = WorkflowClient.newInstance(service);

        // Create a Workflow stub with options
        RandomNumberWorkFlow workflow = client.newWorkflowStub(
                RandomNumberWorkFlow.class,
                WorkflowOptions.newBuilder()
                        .setWorkflowId("random-number-task")
                        .setTaskQueue("random-number-task-queue")
                        .setRetryOptions(
                                RetryOptions.newBuilder()
                                        .setInitialInterval(Duration.ofMillis(10))
                                        .setBackoffCoefficient(1.0)
                                        .setMaximumInterval(Duration.ofMinutes(1))
                                        //.setMaximumAttempts(1)
                                        .build()
                        )
                        .build()
        );

        // Execute the Workflow synchronously (blocks until complete)
        return workflow.getRandomNumber(min, max);
    }

    @GetMapping("/async")
    public int getRandomNumberAsync(@RequestParam int min, @RequestParam int max) {
        // Connect to Temporal
        WorkflowServiceStubs service = WorkflowServiceStubs.newServiceStubs(
                WorkflowServiceStubsOptions.newBuilder()
                        .setTarget(temporalServer)
                        .build()
        );
        WorkflowClient client = WorkflowClient.newInstance(service);

        // Create a Workflow stub with options
        RandomNumberAsyncWorkFlow workflow = client.newWorkflowStub(
                RandomNumberAsyncWorkFlow.class,
                WorkflowOptions.newBuilder()
                        .setWorkflowId("random-number-task-queue-async")
                        .setTaskQueue("random-number-task-queue")
                        .setRetryOptions(
                                RetryOptions.newBuilder()
                                        .setInitialInterval(Duration.ofMillis(10))
                                        .setBackoffCoefficient(1.0)
                                        .setMaximumInterval(Duration.ofMinutes(1))
                                        //.setMaximumAttempts(1)
                                        .build()
                        )
                        .build()
        );

        // Execute the Workflow synchronously (blocks until complete)
        return workflow.getRandomNumber(min, max);
    }
}

The setWorkflowId is a business-level identifier. If you start a workflow with the same ID while one is already running, Temporal will reject the duplicate by default — preventing accidental double-processing.

You can track and find executions at http://localhost:8233

What Happens When Things Fail?

This is where Temporal truly shines. Try this experiment:

  1. Start the workflow via the Starter.
  2. Kill the Worker process mid-execution.
  3. Restart the Worker.

Temporal will pick up right where it left off. The workflow doesn’t re-execute activities that already completed successfully — it replays the event history, skips the completed activities, and resumes from the point of failure. No data loss. No duplicate processing.

How Event History and Replay Actually Work

This “magic” deserves a deeper explanation because it’s the core of what makes Temporal powerful.

Event History: The Source of Truth

Every action in a workflow execution is recorded as an Event in a durable Event History stored by the Temporal Server. When your workflow calls an activity, Temporal records events like:

  • ActivityTaskScheduled — the workflow requested the activity
  • ActivityTaskStarted — a worker picked up the activity
  • ActivityTaskCompleted — the activity finished (with its return value stored)

This history persists in the database. Even if the Temporal Server itself crashes and restarts, the history survives.

Replay: Reconstructing State Without Re-Executing

When a Worker needs to continue a workflow (after a crash, a restart, or simply because the workflow was evicted from cache), it fetches the Event History from the Temporal Server and replays the workflow code from the beginning.

Here’s the key insight: replay doesn’t re-execute activities. Instead, it uses the recorded results to fast-forward through already-completed work.

Let’s walk through a concrete example. Imagine this workflow:

@Override
public int getRandomNumber(int min, int max) {
    int n1 = activities.getRandomNumber(min, max);
    int n2 = activities.getRandomNumber(min, max);

    return n1 + n2;
}

Scenario: The worker crashes after Activity 1 completes but before Activity 2 starts.

What’s in the Event History at crash time:

  1. WorkflowExecutionStarted
  2. WorkflowTaskScheduled / Started / Completed
  3. ActivityTaskScheduled (Activity 1)
  4. ActivityTaskStarted (Activity 1)
  5. ActivityTaskCompleted (Activity 1, result: "1")

What happens on replay:

  • A new Worker picks up the workflow.
  • It fetches the Event History from the server.
  • It starts executing the workflow code from the beginning.
  • When the code reaches activities.getRandomNumber(min, max), the SDK checks the history: "Is there an ActivityTaskScheduled event here?" Yes. "Is there a corresponding ActivityTaskCompleted?" Yes, with result "1".
  • Instead of scheduling a new activity, the SDK returns the stored result immediately.
  • The code continues to the second activity call. The SDK checks again: “Is there an ActivityTaskScheduled for this?" No — we've reached the end of the recorded history.
  • Now the workflow resumes normal execution: it schedules Activity 2, which runs for real this time.

The workflow code runs again, but activity side effects don’t repeat. This is why workflows must be deterministic — if the code took a different path on replay (due to Math.random() or System.currentTimeMillis()), the commands it generates wouldn't match the recorded history, and Temporal would throw a non-determinism error.

Why This Matters

This replay mechanism gives you:

  • Fault tolerance: Workers can crash at any point. Another worker (or the same one after restart) will seamlessly continue.
  • No duplicate processing: Activities that completed won’t run again — their results are already in the history.
  • Durable state: You don’t need to manually persist workflow state to a database. Temporal does it automatically via the event history.
  • Time travel debugging: You can inspect the full history in the Web UI to see exactly what happened and when.

Configuring Retry Policies

You can configure custom retry policies on your activities:

ActivityOptions options = ActivityOptions.newBuilder()
        .setStartToCloseTimeout(Duration.ofSeconds(30))
        .setRetryOptions(
                RetryOptions.newBuilder()
                        .setInitialInterval(Duration.ofSeconds(1))
                        .setMaximumInterval(Duration.ofMinutes(1))
                        .setBackoffCoefficient(2.0)
                        .setMaximumAttempts(5)
                        .build()
        )
        .build();

Heartbeats for Long-Running Activities

For activities that run longer than a few minutes, heartbeats serve two critical purposes:

  1. Faster failure detection — Without heartbeats, Temporal only knows an activity failed when the StartToCloseTimeout expires. For a 2-hour activity, that's a 2-hour wait before retry. With heartbeats, Temporal detects failure within seconds.
  2. Progress checkpointing — Heartbeats can carry progress data. If the activity fails and retries, it can resume from where it left off instead of starting over.

When to Use Heartbeats

  • Quick API call (< 30s):
  • No: Overhead not worth it
  • Database query:
  • No: Too fast
  • Processing 10,000 records:
  • Yes: Report progress, enable resume
  • Uploading large file to S3:
  • Yes: Detect stalled uploads
  • ML model training:
  • Yes: Long-running, need progress
  • Polling external service:
  • Yes: Prove activity is still alive

Configuring Heartbeat Timeout

Set the heartbeat timeout in your ActivityOptions:

ActivityOptions options = ActivityOptions.newBuilder()
        .setStartToCloseTimeout(Duration.ofHours(2))
        .setHeartbeatTimeout(Duration.ofSeconds(30))  // Must heartbeat every 30s
        .setRetryOptions(
                RetryOptions.newBuilder()
                        .setMaximumAttempts(3)
                        .build()
        )
        .build();

If the Temporal server doesn’t receive a heartbeat within 30 seconds, it considers the activity failed and schedules a retry (if retry policy allows).

Sending Heartbeats from Activity Code

Use Activity.getExecutionContext().heartbeat():

public class DataProcessingActivitiesImpl implements DataProcessingActivities {

    @Override
    public ProcessingResult processLargeDataset(String datasetId) {
        List<Record> records = loadRecords(datasetId);
        List<Record> processed = new ArrayList<>();

        for (int i = 0; i < records.size(); i++) {
            // Process each record
            Record result = processRecord(records.get(i));
            processed.add(result);

            // Heartbeat every 100 records with progress
            if (i % 100 == 0) {
                int progressPercent = (i * 100) / records.size();
                Activity.getExecutionContext().heartbeat(progressPercent);
            }
        }

        return new ProcessingResult(processed);
    }
}

The SDK automatically throttles heartbeats — you can call heartbeat() as often as you want (even every loop iteration), and the SDK batches them to avoid overwhelming the server.

Resuming from Heartbeat Progress

The real power of heartbeats: if an activity fails and retries, it can retrieve the last heartbeat data and resume from that point:

@Override
public ProcessingResult processLargeDataset(String datasetId) {
    List<Record> records = loadRecords(datasetId);
    List<Record> processed = new ArrayList<>();

    // Check if we're resuming from a previous attempt
    int startIndex = 0;
    Optional<Integer> lastProgress = Activity.getExecutionContext().getHeartbeatDetails(Integer.class);

    if (lastProgress.isPresent()) {
        startIndex = lastProgress.get();
        System.out.println("Resuming from record " + startIndex);
        // Reload already-processed records if needed
        processed = loadProcessedRecords(datasetId, startIndex);
    }

    // Continue processing from where we left off
    for (int i = startIndex; i < records.size(); i++) {
        Record result = processRecord(records.get(i));
        processed.add(result);

        if (i % 100 == 0) {
            // Heartbeat with current index so we can resume here
            Activity.getExecutionContext().heartbeat(i);
        }
    }

    return new ProcessingResult(processed);
}

Detecting Cancellation via Heartbeat

Heartbeating is also how activities learn they’ve been cancelled. The heartbeat() call throws ActivityCompletionException if cancellation was requested:

@Override
public void longRunningTask() {
    for (int i = 0; i < 10000; i++) {
        doWork(i);

        try {
            Activity.getExecutionContext().heartbeat(i);
        } catch (ActivityCompletionException e) {
            // Workflow requested cancellation — clean up and exit
            System.out.println("Cancellation requested, cleaning up...");
            cleanup();
            throw e; // Re-throw to signal cancellation
        }
    }
}

Important: Activities that don’t heartbeat cannot receive cancellation signals. The activity will run to completion even if the workflow tries to cancel it.

Exception Handling and Failure Recovery

Understanding how Temporal handles exceptions is crucial — get it wrong and you’ll see mysterious 10-second retry loops that ignore your carefully configured retry policies.

The Two Types of Failures

Temporal distinguishes between two fundamentally different failure types:

  • Activity Failure: Activity retries per your config, then error returned to workflow
  • Workflow Task Failure: Fixed ~10s exponential backoff, retries indefinitely

This is the #1 gotcha for Temporal beginners. If you see retries happening every ~10 seconds regardless of your RetryOptions, you have a Workflow Task failure — something is wrong in your workflow code itself.

Check your Event History in the Web UI:

  • ActivityTaskFailed → Activity failure (good, uses your retry config)
  • WorkflowTaskFailed → Workflow Task failure (bad, your config is ignored)

What’s Retryable by Default?

In activities, all exceptions are retryable by default:

// All of these will retry according to your RetryOptions
throw new RuntimeException("Network error");
throw new IOException("Connection refused");
throw new MyCustomException("Something failed");
throw ApplicationFailure.newFailure("Explicit failure", "MyErrorType");

Making Exceptions Non-Retryable

Some failures shouldn’t be retried — bad input, business rule violations, or permanent errors. Two ways to handle this:

Option 1: Throw ApplicationFailure with non-retryable flag

@Override
public void chargeCard(String cardId, BigDecimal amount) {
    try {
        paymentService.charge(cardId, amount);
    } catch (InsufficientFundsException e) {
        // Don't retry — customer doesn't have money
        throw ApplicationFailure.newNonRetryableFailure(
            "Insufficient funds",
            "InsufficientFunds"
        );
    } catch (InvalidCardException e) {
        // Don't retry — bad input
        throw ApplicationFailure.newNonRetryableFailure(
            "Invalid card number",
            "InvalidCard"
        );
    }
    // Other exceptions (network, timeout) will retry automatically
}

Option 2: Configure in RetryOptions

ActivityOptions options = ActivityOptions.newBuilder()
        .setStartToCloseTimeout(Duration.ofSeconds(30))
        .setRetryOptions(
                RetryOptions.newBuilder()
                        .setInitialInterval(Duration.ofSeconds(1))
                        .setMaximumAttempts(5)
                        .setDoNotRetry(
                            InsufficientFundsException.class.getName(),
                            InvalidCardException.class.getName(),
                            "java.lang.IllegalArgumentException"
                        )
                        .build()
        )
        .build();

Handling Activity Failures in Workflow Code

When an activity exhausts its retries or throws a non-retryable exception, it throws ActivityFailure in the workflow. You must catch this specific type:

@Override
public OrderResult processOrder(OrderData order) {
    try {
        activities.chargeCard(order.getCardId(), order.getAmount());
    } catch (ActivityFailure e) {
        // Activity failed after all retries exhausted

        // Unwrap to get the original exception
        Throwable cause = e.getCause(); // ApplicationFailure

        if (cause instanceof ApplicationFailure) {
            String errorType = ((ApplicationFailure) cause).getType();

            if ("InsufficientFunds".equals(errorType)) {
                // Handle insufficient funds — maybe notify customer
                activities.notifyCustomer(order.getCustomerId(), 
                    "Payment failed: insufficient funds");
                throw ApplicationFailure.newFailure(
                    "Order failed: payment declined", "PaymentDeclined");
            }
        }

        // For other failures, run compensation and fail
        activities.releaseInventory(order.getItems());
        throw ApplicationFailure.newFailure("Order failed", "OrderError");
    }

    activities.shipOrder(order);
    return new OrderResult(Status.COMPLETED);
}

Critical: Don’t let random exceptions escape your workflow code. Uncaught non-ApplicationFailure exceptions cause Workflow Task failures (the 10-second retry loop).

// ❌ BAD — RuntimeException in workflow causes Workflow Task failure
@Override
public OrderResult processOrder(OrderData order) {
    String result = activities.fetchData();
    if (result == null) {
        throw new RuntimeException("Bad data");  // Workflow Task crash!
    }
}

// ✅ GOOD — Use ApplicationFailure to fail the workflow cleanly
@Override
public OrderResult processOrder(OrderData order) {
    String result = activities.fetchData();
    if (result == null) {
        throw ApplicationFailure.newFailure("Bad data", "ValidationError");
    }
}

Delayed Retries with Workflow.sleep()

Sometimes you want to retry an activity after a longer delay — maybe an external service is down for maintenance, or you’re implementing a polling pattern. Use Workflow.sleep():

@Override
public OrderResult processOrder(OrderData order) {
    int maxAttempts = 3;

    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            activities.chargeCard(order.getCardId(), order.getAmount());
            break; // Success
        } catch (ActivityFailure e) {
            if (attempt == maxAttempts) {
                throw ApplicationFailure.newFailure(
                    "Payment failed after " + maxAttempts + " attempts", 
                    "PaymentError"
                );
            }
            // Wait before next attempt: 5min, 10min, 15min
            Workflow.sleep(Duration.ofMinutes(5 * attempt));
        }
    }

    activities.shipOrder(order);
    return new OrderResult(Status.COMPLETED);
}

Important: Workflow.sleep() doesn't hold any resources. The workflow suspends completely:

  • Worker completes the Workflow Task and is free to do other work
  • Temporal server holds a durable timer
  • When the timer fires, any available worker picks up the workflow and continues
  • You can even shut down all workers during the sleep — workflow resumes when one comes back online

Securing Temporal with TLS

For production deployments, you’ll want to enable TLS (or mTLS for mutual authentication). This section covers setting up a self-hosted Temporal server with mTLS.

Step 1: Generate Certificates

You need three certificates: CA (Certificate Authority), server, and client.

# Create certs directory
mkdir -p certs && cd certs

# 1. Generate CA (Certificate Authority)
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 365 -key ca.key -out ca.pem \
    -subj "/CN=Temporal CA"

# 2. Generate Server certificate
openssl genrsa -out server.key 4096
openssl req -new -key server.key -out server.csr \
    -subj "/CN=temporal-server"

cat > server.ext << EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = temporal
DNS.3 = temporal-server
DNS.4 = temporal.mycompany.com
IP.1 = 127.0.0.1
EOF

openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
    -out server.pem -days 365 -extfile server.ext

# 3. Generate Client certificate
openssl genrsa -out client.key 4096
openssl req -new -key client.key -out client.csr \
    -subj "/CN=temporal-client"

cat > client.ext << EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
EOF

openssl x509 -req -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
    -out client.pem -days 365 -extfile client.ext

cd ..

Step 2: Docker/Podman Compose with mTLS (refer to https://github.com/temporalio/samples-server/tree/main/compose for up-to-date configs)

# docker-compose-tls.yml
version: "3.8"

services:
  postgresql:
    image: postgres:16
    environment:
      POSTGRES_USER: temporal
      POSTGRES_PASSWORD: temporal
    volumes:
      - pgdata:/var/lib/postgresql/data

  temporal:
    image: temporalio/auto-setup:1.25.2
    depends_on:
      - postgresql
    ports:
      - "7233:7233"
    volumes:
      - ./certs:/etc/temporal/certs
    environment:
      - DB=postgres12
      - DB_PORT=5432
      - POSTGRES_USER=temporal
      - POSTGRES_PWD=temporal
      - POSTGRES_SEEDS=postgresql

      # Server TLS (internode communication)
      - TEMPORAL_TLS_SERVER_CA_CERT=/etc/temporal/certs/ca.pem
      - TEMPORAL_TLS_SERVER_CERT=/etc/temporal/certs/server.pem
      - TEMPORAL_TLS_SERVER_KEY=/etc/temporal/certs/server.key

      # Frontend TLS (client connections)
      - TEMPORAL_TLS_FRONTEND_CA_CERT=/etc/temporal/certs/ca.pem
      - TEMPORAL_TLS_FRONTEND_CERT=/etc/temporal/certs/server.pem
      - TEMPORAL_TLS_FRONTEND_KEY=/etc/temporal/certs/server.key

      # Require client certificates (mTLS)
      - TEMPORAL_TLS_REQUIRE_CLIENT_AUTH=true
      - TEMPORAL_TLS_CLIENT1_CA_CERT=/etc/temporal/certs/ca.pem

      # Server name for verification
      - TEMPORAL_TLS_INTERNODE_SERVER_NAME=temporal-server

  temporal-ui:
    image: temporalio/ui:latest
    depends_on:
      - temporal
    ports:
      - "8080:8080"
    volumes:
      - ./certs:/etc/temporal/certs
    environment:
      - TEMPORAL_ADDRESS=temporal:7233
      - TEMPORAL_TLS_CA=/etc/temporal/certs/ca.pem
      - TEMPORAL_TLS_CERT=/etc/temporal/certs/client.pem
      - TEMPORAL_TLS_KEY=/etc/temporal/certs/client.key
      - TEMPORAL_TLS_ENABLE_HOST_VERIFICATION=false

volumes:
  pgdata:

Step 3: Start the Server

docker-compose -f docker-compose-tls.yml up

Step 4: Connect Java Client with mTLS

SslContext sslContext = SimpleSslContextBuilder.forPKCS8(
        new File("certs/client.pem"),     // Client certificate
        new File("certs/client.key")       // Client private key
)
.setTrustManager(new File("certs/ca.pem")) // CA to verify server
.build();

WorkflowServiceStubs service = WorkflowServiceStubs.newServiceStubs(
        WorkflowServiceStubsOptions.newBuilder()
                .setTarget("localhost:7233")
                .setSslContext(sslContext)
                .build()
);

WorkflowClient client = WorkflowClient.newInstance(service);

TLS Environment Variables Reference

  • TEMPORAL_TLS_SERVER_CERT — Server certificate for internode communication
  • TEMPORAL_TLS_SERVER_KEY — Server private key
  • TEMPORAL_TLS_SERVER_CA_CERTCA — to verify other nodes
  • TEMPORAL_TLS_FRONTEND_CERT — Certificate for frontend (client-facing)
  • TEMPORAL_TLS_FRONTEND_KEY — Frontend private key
  • TEMPORAL_TLS_REQUIRE_CLIENT_AUTH — true enables mTLS (clients must present cert)
  • TEMPORAL_TLS_CLIENT1_CA_CERTCA — to verify client certificates
  • TEMPORAL_TLS_INTERNODE_SERVER_NAME — Expected server name for verification

Alternative: TLS Termination with Nginx

For more control, you can terminate TLS at Nginx and run Temporal without TLS internally:

# /etc/nginx/nginx.conf
stream {
    upstream temporal_grpc {
        server temporal:7233;
    }

    server {
        listen 7233 ssl;

        ssl_certificate /etc/nginx/certs/server.pem;
        ssl_certificate_key /etc/nginx/certs/server.key;
        ssl_client_certificate /etc/nginx/certs/ca.pem;
        ssl_verify_client on;  # Enables mTLS

        proxy_pass temporal_grpc;
    }
}

http {
    server {
        listen 443 ssl;

        ssl_certificate /etc/nginx/certs/server.pem;
        ssl_certificate_key /etc/nginx/certs/server.key;

        location / {
            proxy_pass http://temporal-ui:8080;
        }
    }
}

This approach keeps Temporal server configuration simple while Nginx handles all TLS complexity.

Execution Context: Accessing Runtime Information

Both workflows and activities have access to execution context — useful for logging, debugging, and conditional logic based on retry attempts.

Activity Context

import io.temporal.activity.Activity;
import io.temporal.activity.ActivityExecutionContext;
import io.temporal.activity.ActivityInfo;

@Override
public String processData(String input) {
    ActivityExecutionContext ctx = Activity.getExecutionContext();
    ActivityInfo info = ctx.getInfo();

    // Attempt number (1-based) — useful for logging retries
    int attempt = info.getAttempt();

    // Activity identifiers
    String activityId = info.getActivityId();
    String activityType = info.getActivityType();

    // Parent workflow info
    String workflowId = info.getWorkflowId();
    String runId = info.getRunId();
    String workflowType = info.getWorkflowType();

    // Task queue and namespace
    String taskQueue = info.getTaskQueue();
    String namespace = info.getNamespace();

    // Timeouts configured for this activity
    Duration startToClose = info.getStartToCloseTimeout();
    Duration heartbeatTimeout = info.getHeartbeatTimeout();

    // Timestamps
    Instant scheduledTime = info.getScheduledTime();

    // Task token (for async completion)
    byte[] taskToken = ctx.getTaskToken();

    if (attempt > 1) {
        System.out.printf("Retry attempt %d for activity %s in workflow %s%n", 
            attempt, activityType, workflowId);
    }

    return "processed";
}

Workflow Context

import io.temporal.workflow.Workflow;
import io.temporal.workflow.WorkflowInfo;

@Override
public OrderResult processOrder(OrderData order) {
    WorkflowInfo info = Workflow.getInfo();

    // Workflow identifiers
    String workflowId = info.getWorkflowId();
    String runId = info.getRunId();
    String workflowType = info.getWorkflowType();

    // Task queue and namespace
    String taskQueue = info.getTaskQueue();
    String namespace = info.getNamespace();

    // Attempt number (if workflow has retry policy)
    int attempt = info.getAttempt();

    // Timing
    Instant startTime = info.getRunStartTime();

    // Parent workflow (if this is a child workflow)
    Optional<String> parentWorkflowId = info.getParentWorkflowId();
    Optional<String> parentRunId = info.getParentRunId();

    // History length (useful for deciding when to continue-as-new)
    long historyLength = info.getHistoryLength();

    Workflow.getLogger(this.getClass()).info(
        "Processing order in workflow {} (attempt {}, history size {})", 
        workflowId, attempt, historyLength);

    return activities.process(order);
}

Custom Context with Search Attributes

Need to pass custom data like client IP address or user ID? Temporal doesn’t capture these automatically, but you can use Search Attributes to make them queryable.

Creating Search Attributes

First, register custom search attributes with the server:

temporal operator search-attribute create --name ClientIp --type Keyword
temporal operator search-attribute create --name UserId --type Keyword
temporal operator search-attribute create --name OrderValue --type Double
temporal operator search-attribute create --name Priority --type Int

Setting Search Attributes When Starting a Workflow

WorkflowOptions options = WorkflowOptions.newBuilder()
        .setTaskQueue("order-queue")
        .setWorkflowId("order-" + orderId)
        .setTypedSearchAttributes(
            TypedSearchAttributes.newBuilder()
                .set(SearchAttributeKey.forKeyword("ClientIp"), request.getRemoteAddr())
                .set(SearchAttributeKey.forKeyword("UserId"), currentUser.getId())
                .set(SearchAttributeKey.forDouble("OrderValue"), order.getTotalAmount())
                .set(SearchAttributeKey.forLong("Priority"), order.getPriority())
                .build()
        )
        .build();

OrderWorkflow workflow = client.newWorkflowStub(OrderWorkflow.class, options);
workflow.processOrder(order);

Updating Search Attributes During Workflow Execution

@Override
public OrderResult processOrder(OrderData order) {
    try {
        activities.validateOrder(order);

        // Update status as searchable attribute
        Workflow.upsertTypedSearchAttributes(
            SearchAttributeUpdate.valueSet(
                SearchAttributeKey.forKeyword("OrderStatus"),
                "VALIDATED"
            )
        );

        activities.processPayment(order);

        Workflow.upsertTypedSearchAttributes(
            SearchAttributeUpdate.valueSet(
                SearchAttributeKey.forKeyword("OrderStatus"),
                "PAID"
            )
        );

        return new OrderResult(Status.COMPLETED);

    } catch (ActivityFailure e) {
        // Record failure reason as searchable
        Workflow.upsertTypedSearchAttributes(
            SearchAttributeUpdate.valueSet(
                SearchAttributeKey.forKeyword("FailureReason"),
                e.getCause().getClass().getSimpleName()
            )
        );
        throw ApplicationFailure.newFailure("Order failed", "OrderError");
    }
}

Querying Failed Workflows and Activities

Temporal provides powerful query capabilities via CLI, Web UI, and SDK.

# List all failed workflows
temporal workflow list --query "ExecutionStatus = 'Failed'"

# Failed in last 24 hours
temporal workflow list --query "ExecutionStatus = 'Failed' AND CloseTime > '2024-01-15T00:00:00Z'"

# Failed workflows of specific type
temporal workflow list --query "ExecutionStatus = 'Failed' AND WorkflowType = 'OrderWorkflow'"

# Query by custom search attribute
temporal workflow list --query "FailureReason = 'InsufficientFunds'"

# Timed out workflows
temporal workflow list --query "ExecutionStatus = 'TimedOut'"

# All non-successful workflows
temporal workflow list --query "ExecutionStatus IN ('Failed', 'Terminated', 'TimedOut', 'Canceled')"

# Running longer than expected
temporal workflow list --query "ExecutionStatus = 'Running' AND StartTime < '2024-01-14T00:00:00Z'"

# By workflow ID prefix
temporal workflow list --query "WorkflowId STARTS_WITH 'order-'"

# Get details of specific failed workflow
temporal workflow describe --workflow-id order-123

# Show full history including failure details
temporal workflow show --workflow-id order-123
public class WorkflowQueryService {

    private final WorkflowClient client;

    // List failed workflows
    public List<WorkflowExecutionInfo> getFailedWorkflows(String workflowType, Instant since) {
        String query = String.format(
            "ExecutionStatus = 'Failed' AND WorkflowType = '%s' AND CloseTime > '%s'",
            workflowType,
            since.toString()
        );

        ListWorkflowExecutionsRequest request = ListWorkflowExecutionsRequest.newBuilder()
                .setNamespace("default")
                .setQuery(query)
                .setPageSize(100)
                .build();

        ListWorkflowExecutionsResponse response = client.getWorkflowServiceStubs()
                .blockingStub()
                .listWorkflowExecutions(request);

        return response.getExecutionsList().stream()
                .map(this::toWorkflowExecutionInfo)
                .collect(Collectors.toList());
    }

    // Paginate through large result sets
    public void processAllFailedWorkflows(Consumer<WorkflowExecutionInfo> processor) {
        ByteString nextPageToken = ByteString.EMPTY;

        do {
            ListWorkflowExecutionsRequest request = ListWorkflowExecutionsRequest.newBuilder()
                    .setNamespace("default")
                    .setQuery("ExecutionStatus = 'Failed'")
                    .setPageSize(100)
                    .setNextPageToken(nextPageToken)
                    .build();

            ListWorkflowExecutionsResponse response = client.getWorkflowServiceStubs()
                    .blockingStub()
                    .listWorkflowExecutions(request);

            response.getExecutionsList().forEach(info ->
                processor.accept(toWorkflowExecutionInfo(info))
            );

            nextPageToken = response.getNextPageToken();
        } while (!nextPageToken.isEmpty());
    }

    // Get failed activities from workflow history
    public List<ActivityFailureInfo> getFailedActivities(String workflowId) {
        List<ActivityFailureInfo> failures = new ArrayList<>();

        GetWorkflowExecutionHistoryRequest request = GetWorkflowExecutionHistoryRequest.newBuilder()
                .setNamespace("default")
                .setExecution(WorkflowExecution.newBuilder()
                        .setWorkflowId(workflowId)
                        .build())
                .build();

        GetWorkflowExecutionHistoryResponse response = client.getWorkflowServiceStubs()
                .blockingStub()
                .getWorkflowExecutionHistory(request);

        for (HistoryEvent event : response.getHistory().getEventsList()) {
            if (event.getEventType() == EventType.EVENT_TYPE_ACTIVITY_TASK_FAILED) {
                ActivityTaskFailedEventAttributes attrs = 
                    event.getActivityTaskFailedEventAttributes();
                failures.add(new ActivityFailureInfo(
                        attrs.getScheduledEventId(),
                        attrs.getFailure().getMessage(),
                        attrs.getFailure().getStackTrace(),
                        event.getEventTime()
                ));
            }
        }

        return failures;
    }
}

Building a Failed Workflow Report

public void generateFailureReport(Instant since) {
    String query = String.format(
        "ExecutionStatus = 'Failed' AND CloseTime > '%s'",
        since.toString()
    );

    ListWorkflowExecutionsRequest request = ListWorkflowExecutionsRequest.newBuilder()
            .setNamespace("default")
            .setQuery(query)
            .setPageSize(100)
            .build();

    ListWorkflowExecutionsResponse response = client.getWorkflowServiceStubs()
            .blockingStub()
            .listWorkflowExecutions(request);

    System.out.println("=== Failed Workflows Report ===\n");

    for (WorkflowExecutionInfo info : response.getExecutionsList()) {
        System.out.printf("Workflow: %s%n", info.getExecution().getWorkflowId());
        System.out.printf("  Type: %s%n", info.getType().getName());
        System.out.printf("  Run ID: %s%n", info.getExecution().getRunId());
        System.out.printf("  Started: %s%n", toInstant(info.getStartTime()));
        System.out.printf("  Failed: %s%n", toInstant(info.getCloseTime()));

        // Get failure details
        printFailureDetails(info.getExecution().getWorkflowId());
        System.out.println();
    }
}

private void printFailureDetails(String workflowId) {
    GetWorkflowExecutionHistoryRequest request = GetWorkflowExecutionHistoryRequest.newBuilder()
            .setNamespace("default")
            .setExecution(WorkflowExecution.newBuilder()
                    .setWorkflowId(workflowId)
                    .build())
            .build();

    GetWorkflowExecutionHistoryResponse response = client.getWorkflowServiceStubs()
            .blockingStub()
            .getWorkflowExecutionHistory(request);

    for (HistoryEvent event : response.getHistory().getEventsList()) {
        if (event.getEventType() == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_FAILED) {
            Failure failure = event.getWorkflowExecutionFailedEventAttributes().getFailure();
            System.out.printf("  Failure: %s%n", failure.getMessage());
        }
    }
}

Wrapping Up

In just a few minutes, we’ve gone from zero to a running Temporal environment with a fully functional Java workflow. Here’s a recap of what we covered:

  • Docker setup using the Temporal CLI (recommended) or Docker Compose for a production-like environment
  • Activity definition for encapsulating non-deterministic business logic
  • Workflow definition for orchestrating activities in a durable, fault-tolerant manner
  • Worker setup for hosting and executing workflows and activities
  • Client/Starter for triggering workflow executions
  • Parallel execution with Async.function() and Promise.allOf() for fan-out/fan-in patterns
  • Heartbeats for long-running activities — faster failure detection, progress reporting, and resumable execution
  • Exception handling — understanding the critical difference between Activity failures and Workflow Task failures, and how to properly catch, retry, and compensate
  • Remote connections — configuring clients for self-hosted servers and Temporal Cloud
  • TLS/mTLS security — generating certificates and securing Temporal with mutual TLS
  • Execution context — accessing runtime information like attempt number, workflow ID, and timestamps
  • Search attributes — adding custom queryable metadata like client IP, user ID, and failure reasons
  • Querying workflows — finding failed workflows and activities using CLI, Web UI, and SDK

Temporal eliminates an enormous category of infrastructure complexity from distributed applications. Instead of building retry logic, state machines, and failure recovery into every service, you write straightforward code and let Temporal handle the rest.

Where to Go from Here

Sample source code is available here


메타데이터
post_id
3d098a4b85fb
slug
getting-started-with-temporal-io-docker-podman-setup-and-your-first-java-workflow-3d098a4b85fb
url
https://medium.com/@alexmachekhin/getting-started-with-temporal-io-docker-podman-setup-and-your-first-java-workflow-3d098a4b85fb
canonical_url
https://medium.com/@alexmachekhin/getting-started-with-temporal-io-docker-podman-setup-and-your-first-java-workflow-3d098a4b85fb
author_url
https://medium.com/@alexmachekhin
status
ok
fetched_at
2026-06-24 11:06:28