Temporal is a durable execution engine: it records every step of a workflow in a database, so a long-running process (an order pipeline, a provisioning job, a billing run) survives crashes, deploys and restarts and resumes exactly where it stopped. Your code runs in workers that you host; the Temporal server only stores state and hands out tasks. In this tutorial you will install the Temporal server with the official Temporal CLI on Ubuntu 24.04, run it as a systemd service with persistent storage, and build a Python worker that uses retry policies and signals.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM.
  • A non-root user with sudo privileges.
  • SSH access from your workstation, used to open the Temporal Web UI through a tunnel.
  • Basic familiarity with Python and async functions.

Step 1 - Installing the Temporal CLI

The Temporal CLI is a single static binary that includes both the command-line client and a complete Temporal server with the Web UI. Download the latest Linux build from Temporal's download service. Use platform=linux&arch=arm64 instead if your server is ARM.

cd /tmp
curl -fsSL -o temporal.tar.gz "https://temporal.download/cli/archive/latest?platform=linux&arch=amd64"
tar -xzf temporal.tar.gz temporal
sudo install -m 0755 temporal /usr/local/bin/temporal

Check that the binary is on your PATH:

temporal --version
temporal version 1.x.x (Server 1.x.x, UI 2.x.x)

Step 2 - Running the Temporal server as a systemd service

Running the server under systemd means it starts at boot and restarts on failure. Create a dedicated system user that owns the database file:

sudo useradd --system --create-home --home-dir /var/lib/temporal --shell /usr/sbin/nologin temporal

Create the unit file:

sudo nano /etc/systemd/system/temporal.service
[Unit]
Description=Temporal server (single node, SQLite)
After=network-online.target
Wants=network-online.target

[Service]
User=temporal
Group=temporal
WorkingDirectory=/var/lib/temporal
ExecStart=/usr/local/bin/temporal server start-dev --db-filename /var/lib/temporal/temporal.db
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

The --db-filename option is what makes the data persistent. Without it, the server keeps everything in memory and loses all workflows when it stops. By default the server listens only on 127.0.0.1: port 7233 for the gRPC frontend that workers and clients use, and port 8233 for the Web UI.

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now temporal

Verify that it is running:

systemctl status temporal --no-pager
● temporal.service - Temporal server (single node, SQLite)
     Loaded: loaded (/etc/systemd/system/temporal.service; enabled; preset: enabled)
     Active: active (running) since ...

If the status is not active (running), read the logs with sudo journalctl -u temporal -n 50.

Step 3 - Creating a namespace

Namespaces isolate workflows from each other, for example per application or per environment. Each namespace has its own retention period, which controls how long the history of closed workflows is kept. Create a namespace called orders that keeps history for three days:

temporal operator namespace create --namespace orders --retention 72h

List the namespaces to confirm it exists. You will also see default, which the server creates automatically:

temporal operator namespace list

To inspect a single namespace, including its retention, run:

temporal operator namespace describe --namespace orders

The CLI connects to localhost:7233 by default, so these commands need no extra options on the server itself.

Step 4 - Opening the Web UI through an SSH tunnel

The Web UI has no authentication in this setup, so do not expose port 8233 to the internet. Instead, forward it over SSH from your workstation:

ssh -L 8233:localhost:8233 your_user@your_server_ip

Replace your_user and your_server_ip with your SSH user and the server's public IP. While that session is open, browse to http://localhost:8233 on your workstation. Select the orders namespace in the top menu; the workflow list is empty for now.

Step 5 - Setting up the Python project

Workers are ordinary applications that use a Temporal SDK. This tutorial uses the Python SDK. Install the tools to create a virtual environment:

sudo apt update
sudo apt install -y python3-venv

Create the project directory, the virtual environment and install the temporalio package:

mkdir -p ~/temporal-orders
cd ~/temporal-orders
python3 -m venv .venv
.venv/bin/pip install temporalio

Confirm the SDK is importable:

.venv/bin/python -c "import temporalio; print('temporalio OK')"
temporalio OK

Step 6 - Writing activities with a retry policy

Activities are the steps that touch the outside world: calling an API, writing to a database, sending an email. They can fail, and Temporal retries them according to a retry policy. To see retries in action, the charge_card activity below fails on its first two attempts, as a flaky payment gateway would.

nano ~/temporal-orders/activities.py
from temporalio import activity
from temporalio.exceptions import ApplicationError


@activity.defn
async def charge_card(order_id: str) -> str:
    attempt = activity.info().attempt
    activity.logger.info("Charging order %s (attempt %d)", order_id, attempt)

    if order_id.startswith("invalid"):
        # Business errors should not be retried.
        raise ApplicationError("Card was declined", type="CardDeclined", non_retryable=True)

    if attempt < 3:
        # Simulate a transient failure: Temporal will retry this.
        raise RuntimeError("Payment gateway timeout")

    return f"charge-{order_id}"


@activity.defn
async def send_confirmation(order_id: str, tracking_number: str) -> None:
    activity.logger.info("Order %s shipped with tracking %s", order_id, tracking_number)

activity.info().attempt starts at 1 and increases with each retry. Raising an ApplicationError with non_retryable=True tells Temporal that retrying is pointless, so the workflow receives the failure immediately.

Step 7 - Writing the workflow

The workflow is the orchestration logic. It must be deterministic, because Temporal replays it from the recorded history to rebuild its state after a restart. This workflow charges the card with an explicit retry policy, then waits, durably and for as long as needed, for an order_shipped signal before sending the confirmation.

nano ~/temporal-orders/workflows.py
from datetime import timedelta

from temporalio import workflow
from temporalio.common import RetryPolicy

with workflow.unsafe.imports_passed_through():
    from activities import charge_card, send_confirmation


@workflow.defn
class OrderWorkflow:
    def __init__(self) -> None:
        self._tracking_number: str | None = None

    @workflow.signal
    def order_shipped(self, tracking_number: str) -> None:
        self._tracking_number = tracking_number

    @workflow.run
    async def run(self, order_id: str) -> str:
        charge_id = await workflow.execute_activity(
            charge_card,
            order_id,
            start_to_close_timeout=timedelta(seconds=30),
            retry_policy=RetryPolicy(
                initial_interval=timedelta(seconds=2),
                backoff_coefficient=2.0,
                maximum_interval=timedelta(minutes=1),
                maximum_attempts=5,
                non_retryable_error_types=["CardDeclined"],
            ),
        )

        # Durable wait: survives worker and server restarts.
        await workflow.wait_condition(lambda: self._tracking_number is not None)

        await workflow.execute_activity(
            send_confirmation,
            args=[order_id, self._tracking_number],
            start_to_close_timeout=timedelta(seconds=30),
        )
        return f"{charge_id} shipped as {self._tracking_number}"

A few details matter here:

  • start_to_close_timeout is required: it bounds how long a single attempt may run.
  • The retry policy waits 2 s, then 4 s, 8 s and so on up to 1 minute between attempts, and gives up after 5 attempts.
  • imports_passed_through() lets the workflow sandbox reuse the activities module instead of reloading it on every replay.

Step 8 - Running the worker

The worker connects to the server, polls a task queue and executes the workflows and activities registered on it.

nano ~/temporal-orders/worker.py
import asyncio
import logging

from temporalio.client import Client
from temporalio.worker import Worker

from activities import charge_card, send_confirmation
from workflows import OrderWorkflow


async def main() -> None:
    logging.basicConfig(level=logging.INFO)
    client = await Client.connect("localhost:7233", namespace="orders")
    worker = Worker(
        client,
        task_queue="order-processing",
        workflows=[OrderWorkflow],
        activities=[charge_card, send_confirmation],
    )
    await worker.run()


if __name__ == "__main__":
    asyncio.run(main())

Run the worker as a systemd service so it keeps polling after you log out. Create the unit, replacing your_user with your username:

sudo nano /etc/systemd/system/temporal-worker.service
[Unit]
Description=Temporal worker for order-processing
After=temporal.service
Requires=temporal.service

[Service]
User=your_user
WorkingDirectory=/home/your_user/temporal-orders
ExecStart=/home/your_user/temporal-orders/.venv/bin/python worker.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now temporal-worker
systemctl status temporal-worker --no-pager

The status should report active (running). To confirm that the worker is actually polling, describe the task queue; you should see a poller listed for both the workflow and activity task types:

temporal task-queue describe --task-queue order-processing --namespace orders

Step 9 - Starting and signaling a workflow

Start an order workflow from the CLI. The --input value is JSON, so a string argument needs its own double quotes:

temporal workflow start \
  --namespace orders \
  --task-queue order-processing \
  --type OrderWorkflow \
  --workflow-id order-1001 \
  --input '"1001"'

Watch the worker log. The first two charge attempts fail and are retried with backoff, then the third succeeds:

sudo journalctl -u temporal-worker -f

Press Ctrl+C to stop following the log. The workflow is now waiting for the shipping signal. Confirm it is still running:

temporal workflow describe --namespace orders --workflow-id order-1001

Look for a status of Running in the output. Now you can test durability: restart both services while the workflow waits.

sudo systemctl restart temporal temporal-worker

Send the signal. The workflow resumes from the recorded state, not from the beginning, so the card is not charged again:

temporal workflow signal \
  --namespace orders \
  --workflow-id order-1001 \
  --name order_shipped \
  --input '"TRACK123456"'

Display the full event history to verify the result:

temporal workflow show --namespace orders --workflow-id order-1001

The history ends with WorkflowExecutionCompleted, and the result is "charge-1001 shipped as TRACK123456". The same timeline, including activity inputs, outputs and retry attempts, is visible in the Web UI under the orders namespace.

To see a non-retryable failure, start a workflow with an order ID that begins with invalid:

temporal workflow start --namespace orders --task-queue order-processing \
  --type OrderWorkflow --workflow-id order-invalid-1 --input '"invalid-1"'

It fails on the first attempt with CardDeclined instead of retrying five times.

Step 10 - Finding and managing workflows

Use list queries to find workflows by type or status:

temporal workflow list --namespace orders --query 'WorkflowType="OrderWorkflow" AND ExecutionStatus="Running"'

If a workflow must be stopped from outside, cancel it (the workflow code can react and clean up) or terminate it (stops immediately, no cleanup):

temporal workflow terminate --namespace orders --workflow-id order-1002 --reason "Stopped by operator"

Troubleshooting

Workflows stay in Running and nothing happens. No worker is polling the task queue, or the worker uses a different namespace or task queue name. Run temporal task-queue describe --task-queue order-processing --namespace orders and check that pollers are listed, then check sudo journalctl -u temporal-worker -n 50 for connection errors.

The worker fails with a workflow sandbox or non-determinism error. Workflow code must not call the network, read files, use random or datetime.now() directly. Move that work into an activity, or use workflow.now() and workflow.random().

Connection refused on port 7233. The server is not running or is still starting. Check systemctl status temporal and the journal. If a remote worker must connect, bind the server with --ip 0.0.0.0 in the unit file and restrict port 7233 with UFW to the worker's IP only, because the frontend has no authentication in this setup.

All workflows disappeared after a restart. The server was started without --db-filename, so it used in-memory storage. Add the option as shown in Step 2.

Conclusion

You installed the Temporal server with the Temporal CLI, ran it under systemd with persistent SQLite storage, and built a Python worker whose workflow retries a flaky activity, waits durably for a signal and survives a full restart of both server and worker. From here, you can add more activities and child workflows to model real processes, put the worker's configuration (server address, namespace, task queue) into environment variables, and, when you outgrow a single node, move the server to PostgreSQL with Temporal's official Helm charts or container images while keeping the same worker code.