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…
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
defaultnamespace 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.setStartToCloseTimeoutdefines 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. UseWorkflow.sleep()andWorkflow.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:
- Start the workflow via the Starter.
- Kill the Worker process mid-execution.
- 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 activityActivityTaskStarted— a worker picked up the activityActivityTaskCompleted— 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:
WorkflowExecutionStartedWorkflowTaskScheduled/Started/CompletedActivityTaskScheduled(Activity 1)ActivityTaskStarted(Activity 1)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 anActivityTaskScheduledevent here?" Yes. "Is there a correspondingActivityTaskCompleted?" 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
ActivityTaskScheduledfor 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:
- Faster failure detection — Without heartbeats, Temporal only knows an activity failed when the
StartToCloseTimeoutexpires. For a 2-hour activity, that's a 2-hour wait before retry. With heartbeats, Temporal detects failure within seconds. - 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()andPromise.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
- Temporal Java SDK Samples — dozens of patterns including sagas, signals, child workflows, and more
- Temporal Documentation — the complete Java SDK developer guide
- Temporal University — free courses on building Temporal applications
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