Persistent LangGraph Agent Loop on Civo Kubernetes with Managed PostgreSQL

Deploy a LangGraph research agent on Civo Kubernetes that uses Managed PostgreSQL to checkpoint state after every step, so your AI workflows survive pod restarts without losing progress.

11 minutes reading time

Written by

Mostafa Ibrahim
Mostafa Ibrahim

Software Engineer at GoCardless

When a Kubernetes pod restarts, everything stored in memory disappears. For a simple chatbot, that is usually fine. But for an agent running a multi-step research task, it means losing every finding, tool result, and decision made along the way. The task has to start from scratch.

Most agent demos avoid this issue because they are stateless. They process a single request, return a response, and exit. There is no long-running workflow, accumulated context, or state to recover. In production, that quickly becomes a limitation.

A common solution is to run a database inside the cluster, but that adds the overhead of managing infrastructure before you even start building the agent. Another option is storing state in files or object storage, which means writing custom serialization and recovery logic.

A better approach is automatic checkpointing. After every step, the agent saves its state to external storage and can resume from the latest checkpoint if the pod restarts. This tutorial shows you how to build that with LangGraph, Civo Managed PostgreSQL, and relaxAI.

What you'll build

The architecture consists of a LangGraph research agent running as a Kubernetes Deployment on Civo, with Civo Managed PostgreSQL providing persistent state.

The agent starts with a high-level research prompt and runs autonomously. It searches the web, fetches and reads relevant pages, saves its progress to PostgreSQL after every step through LangGraph checkpointing, and decides when it has gathered enough information to generate a final report.

Every action is streamed to the pod logs, making it easy to follow the agent's progress. If the pod restarts at any point, LangGraph restores the latest checkpoint from PostgreSQL, allowing the workflow to continue without repeating completed steps.

The stack includes:

Once deployed, the only manual step is tailing the pod logs to watch the agent work.

Prerequisites

Before you begin, make sure you have the required accounts and tools installed.

Accounts

Local tools

  • Civo CLI for provisioning the cluster and database
  • kubectl for communicating with the cluster
  • Docker for building and pushing the container image
  • Python 3.11 for running the agent locally before containerizing

Tested versions

ToolVersion

Civo CLI

v1.5.2

Kubernetes

v1.34.1

Python

3.11+ 

Docker

29.0.1

LangGraph

1.2.9

LangChain

1.3.13

Project structure

langgraph-civo/
├── agent/
│ ├── agent.py # LangGraph agent definition
│ ├── tools.py # tool definitions
│ └── requirements.txt # Python dependencies
├── Dockerfile # container image
└── kubernetes/
├── namespace.yaml # namespace for all resources
└── deployment.yaml # agent Deployment manifest

Once all tools are installed and both API keys are confirmed, you have everything needed to follow this tutorial from start to finish.

How it fits together

Before starting the deployment, it is useful to understand the role of each component and how they work together. The setup is divided into four main parts, with each handling a specific responsibility.

Persistent LangGraph Agent Loop on Civo Kubernetes with Managed PostgreSQL

The Civo Kubernetes cluster runs the agent as a single Deployment on a CPU node. Kubernetes restarts the pod if it fails, but the agent does not lose its progress because its state is stored outside the cluster.

Inside the pod, LangGraph manages the agent loop. It decides which tool to call, processes the result, and checks whether the task is complete. After each step, it saves the current state to PostgreSQL via LangGraph checkpointing. If the pod restarts, LangGraph loads the latest checkpoint and resumes from where it stopped.

Civo Managed PostgreSQL stores the agent checkpoints. It runs outside the cluster, requires no in-cluster database deployment or persistent volumes, and is accessed through a standard PostgreSQL connection string.

relaxAI handles all LLM requests through an OpenAI-compatible API. The agent sends prompts to relaxAI and receives the next action without running a model inside the cluster.

The workflow is simple:

  1. The agent starts and loads the latest checkpoint from PostgreSQL
  2. It sends the current state to relaxAI and receives the next action
  3. It executes the selected tool and saves the updated state
  4. The loop repeats until the agent finishes the task and generates a final report

Each component has a single role: Kubernetes runs the workload, LangGraph manages the workflow, PostgreSQL stores the state, and relaxAI provides the reasoning.

Provisioning the Database

Before creating the cluster, create the Managed PostgreSQL instance first. The agent requires the database connection string to configure its Kubernetes secret, so the database must be available before deployment.

Authenticate the Civo CLI:

civo apikey save my-key YOUR_CIVO_API_KEY
civo apikey use my-key

Create the PostgreSQL instance:

civo database create langgraph-db \
--size g3.db.small \
--software PostgreSQL \
--region NYC1 \
--wait

The --wait flag holds the terminal until the instance is fully provisioned. Expect under 5 minutes.

Database provisioned and ready

Database provisioned and ready

Once provisioned, retrieve the connection string:

civo db credential langgraph-db

Copy the connection string. It looks like this:

postgresql://civo:password@host:5432

Append /postgres?sslmode=require to get the full URL:

postgresql://civo:password@host:5432/postgres?sslmode=require

To confirm the database is accepting connections, run: 

civo database show langgraph-db --region NYC1 | grep Status

Save the full connection string. You'll use it later when creating the Kubernetes secret. The agent uses this connection string to read and write its checkpoints.

Creating the Cluster

With the database ready, create the Kubernetes cluster. The agent runs on a single CPU node, so no GPU resources are needed.

Create the cluster:

civo kubernetes create langgraph-cluster \
--size g4s.kube.small \
--nodes 1 \
--region NYC1 \
--wait

If the CLI times out, run civo kubernetes ls --region NYC1 and confirm the cluster shows ACTIVE before moving on.

Save the kubeconfig so kubectl can connect to the cluster:

civo kubernetes config langgraph-cluster --region NYC1 --save --switch

Verify kubectl is connected:

kubectl cluster-info

Expected:

Cluster connected successfully

Cluster connected successfully

Verify the node is Ready:

kubectl get nodes

Expected:

Node Ready

Node Ready

The node must show Ready before moving on.

Building the LangGraph Agent

The agent is split across three files: requirements.txt defines the dependencies, tools.py defines what the agent can do, and agent.py wires everything together into a LangGraph graph.

agent/requirements.txt

langgraph
langgraph-checkpoint-postgres
langchain
langchain-openai
langchain-community
psycopg[binary]
python-dotenv
requests
beautifulsoup4
ddgs

agent/tools.py

The agent has three tools. web_search queries DuckDuckGo and returns the top five results. fetch_url retrieves and cleans the text content of a page, returning a descriptive error message if the page is unavailable so the agent knows to search for an alternative. write_report saves the final report to report.md.

import requests
from bs4 import BeautifulSoup
from ddgs import DDGS
from langchain_core.tools import tool
@tool
def web_search(query: str) -> str:
"""Search the web for a given query and return the top 5 results."""
with DDGS() as ddgs:
results = ddgs.text(query, max_results=5)
if not results:
return "No results found. Try a different query."
output = []
for i, r in enumerate(results, 1):
output.append(f"[{i}] Title: {r['title']}\nURL: {r['href']}\nSnippet: {r['body']}\n")
return "\n".join(output)
@tool
def fetch_url(url: str) -> str:
"""Fetch the full content of a URL. Returns cleaned text or an error if unavailable."""
try:
response = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
if response.status_code == 404:
return f"ERROR_404: Page not found at {url}. Do not use this source. Search for an alternative."
if response.status_code != 200:
return f"ERROR_{response.status_code}: Failed to fetch {url}. Search for an alternative."
soup = BeautifulSoup(response.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
lines = [line for line in text.splitlines() if len(line.strip()) > 40]
return "\n".join(lines)[:8000]
except Exception as e:
return f"ERROR: Failed to fetch {url}: {str(e)}. Search for an alternative."
@tool
def write_report(content: str) -> str:
"""Write the final research report to report.md."""
with open("report.md", "w", encoding="utf-8") as f:
f.write(content)
return "Report written to report.md

agent/agent.py

The following snippets are broken into logical parts for clarity. In the actual file, they form one continuous script.

Part 1: Imports and state definition

import os
import uuid
import json
import time
import psycopg
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.prebuilt import ToolNode
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
from tools import web_search, fetch_url, write_report
load_dotenv()
class AgentState(TypedDict):
messages: Annotated[list, add_messages]

Part 2: Model and system prompt

The model is pointed at the relaxAI API using an OpenAI-compatible client. The system prompt enforces the research workflow in three phases: search, read, then write.

model = ChatOpenAI(
base_url="https://api.relax.ai/v1",
api_key=os.getenv("RELAX_API_KEY"),
model="Nemotron-3-Super",
temperature=0,
max_tokens=4096,
)
tools = [web_search, fetch_url, write_report]
model_with_tools = model.bind_tools(tools)
tool_node = ToolNode(tools)
SYSTEM_PROMPT = """You are an expert autonomous research agent.
You MUST follow these steps in order:
1. Call web_search 3 times with different queries
2. Call fetch_url on 2 URLs from the results
3. Call write_report with a structured report
Report structure:
# [Topic] Research Report
## Executive Summary
## Recent Developments
## Key Features
## Real-World Applications
## Limitations
## Future Direction
## Sources
Do not call write_report before completing steps 1 and 2."""

Part 3: Helper functions

safe_trim keeps the message history within the model context window while preserving tool call pairs. is_completed queries PostgreSQL to check if this thread already finished, so the agent does not repeat work after a pod restart.

def safe_trim(messages, max_messages=10):
if len(messages) <= max_messages:
return messages
trimmed = messages[-max_messages:]
while trimmed and isinstance(trimmed[0], ToolMessage):
trimmed = trimmed[1:]
return trimmed
def is_completed(db_url: str, thread_id: str) -> bool:
try:
with psycopg.connect(db_url) as conn:
cur = conn.cursor()
cur.execute(
"SELECT checkpoint FROM checkpoints WHERE thread_id = %s ORDER BY checkpoint_id DESC LIMIT 1;",
(thread_id,)
)
row = cur.fetchone()
if row and row[0]:
checkpoint = json.loads(row[0]) if isinstance(row[0], str) else row[0]
messages = checkpoint.get("channel_values", {}).get("messages", [])
for msg in messages:
if isinstance(msg, dict) and msg.get("name") == "write_report":
return True
return False
except Exception:
return False

Part 4:  Graph definition

The graph has two nodes: agent and tools. The agent node calls the model and returns the next action. The tools node executes the tool call and returns the result. The graph loops between them until the agent calls write_report or the message count exceeds 30.

def agent_node(state: AgentState):
trimmed = safe_trim(state["messages"])
messages = [SystemMessage(content=SYSTEM_PROMPT)] + trimmed
response = model_with_tools.invoke(messages)
return {"messages": [response]}
def should_continue(state: AgentState):
last_message = state["messages"][-1]
if not last_message.tool_calls:
return END
return "tools"
def after_tools(state: AgentState):
for msg in state["messages"]:
if hasattr(msg, "name") and msg.name == "write_report":
return END
if len(state["messages"]) > 30:
return END
return "agent"
def build_graph(checkpointer):
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", should_continue)
graph.add_conditional_edges("tools", after_tools)
return graph.compile(checkpointer=checkpointer)

Part 5: Entry point

On startup, the agent checks PostgreSQL for an existing completed checkpoint. If found, it sleeps to keep the pod alive without repeating the work. If not found, it runs the full research loop and sleeps after completion.

if __name__ == "__main__":
db_url = os.getenv("DATABASE_URL")
prompt = os.getenv("RESEARCH_PROMPT", "Research the latest trends in Kubernetes and cloud native computing")
thread_id = os.getenv("THREAD_ID", str(uuid.uuid4()))
print(f"Starting research: {prompt}")
print(f"Thread ID: {thread_id}\n")
if is_completed(db_url, thread_id):
print("Research already completed for this thread. Sleeping to keep pod alive.")
while True:
time.sleep(3600)
try:
with PostgresSaver.from_conn_string(db_url) as checkpointer:
checkpointer.setup()
graph = build_graph(checkpointer)
config = {"configurable": {"thread_id": thread_id}}
for event in graph.stream(
{"messages": [HumanMessage(content=prompt)]},
config=config,
stream_mode="values"
):
last_message = event["messages"][-1]
last_message.pretty_print()
print("\nResearch completed successfully.")
print("Sleeping to keep pod alive for log inspection.")
while True:
time.sleep(3600)
except Exception as e:
print(f"\nAgent failed with error: {e}")
raise

Combine all five parts into a single agent/agent.py file in the order shown above. 

Containerizing the Agent

With the agent code in place, the next step is packaging it into a container image that Kubernetes can run.

Create the Dockerfile at the project root:

FROM python:3.11-slim
WORKDIR /app
COPY agent/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY agent/ .
CMD ["python", "agent.py"]

Build the image:

docker build -t your-dockerhub-username/langgraph-agent:latest

Push it to Docker Hub:

docker push your-dockerhub-username/langgraph-agent:latest
docker push output

Docker push output

The image is now available for Kubernetes to pull when the Deployment starts.

Configuring the Kubernetes manifests

Two manifest files define the full agent deployment. Create them inside the kubernetes/ folder before applying anything.

kubernetes/namespace.yaml

A dedicated namespace keeps all agent resources isolated from system workloads and makes cleanup a single command.

apiVersion: v1
kind: Namespace
metadata:
name: langgraph

kubernetes/deployment.yaml

The Deployment runs a single replica of the agent pod. Credentials are injected from the Kubernetes secret rather than hardcoded into the manifest. The restartPolicy is set to Always so Kubernetes restarts the pod if it crashes before completing. Once the agent finishes and enters its sleep loop, the pod stays alive with zero restarts.

apiVersion: apps/v1
kind: Deployment
metadata:
name: langgraph-agent
namespace: langgraph
spec:
replicas: 1
selector:
matchLabels:
app: langgraph-agent
template:
metadata:
labels:
app: langgraph-agent
spec:
containers:
- name: langgraph-agent
image: your-dockerhub-username/langgraph-agent:latest
env:
- name: RELAX_API_KEY
valueFrom:
secretKeyRef:
name: langgraph-secret
key: RELAX_API_KEY
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: langgraph-secret
key: DATABASE_URL
- name: RESEARCH_PROMPT
valueFrom:
secretKeyRef:
name: langgraph-secret
key: RESEARCH_PROMPT
- name: THREAD_ID
valueFrom:
secretKeyRef:
name: langgraph-secret
key: THREAD_ID
resources:
requests:
memory: 256Mi
cpu: 250m
limits:
memory: 512Mi
cpu: 500m
restartPolicy: Always

Replace your-dockerhub-username with your actual Docker Hub username before applying.

Deploying and watching the agent run

With the manifests in place, apply them in order. The namespace must exist before the secret, and the secret must exist before the Deployment tries to read from it.

Apply the namespace:

kubectl apply -f kubernetes/namespace.yaml

Create the Kubernetes secret with your credentials:

kubectl create secret generic langgraph-secret \
--from-literal=RELAX_API_KEY=your_relaxai_api_key \
--from-literal=DATABASE_URL=postgresql://civo:password@host:5432/postgres?sslmode=require \
--from-literal=RESEARCH_PROMPT="Research the latest developments in LangGraph and autonomous agents" \
--from-literal=THREAD_ID=research-k8s-001 \
--namespace=langgraph

Apply the Deployment:

kubectl apply -f kubernetes/deployment.yaml

Watch the pod start:

kubectl get pods -n langgraph -w
pod running

Pod running

Tail the logs to watch the agent work in real time:

kubectl logs -f deployment/langgraph-agent -n langgraph
agent calling web_search three times with different queries

Agent calling web_search three times with different queries

The agent starts by calling web_search three times in parallel with different queries, covering different angles of the research topic. The search results come back with titles, URLs, and snippets from across the web. 

Agent calling fetch_url and reading page content

Agent calling fetch_url and reading page content

From the search results, the agent picks two URLs and calls fetch_url on each. When a page returns a 403 or 404, the tool returns a descriptive error message, and the agent searches for an alternative source rather than stopping. 

write_report call and Agent completed successfully

write_report call and Agent completed successfully

After gathering enough information, the agent calls write_report to generate a structured report covering the executive summary, recent developments, key features, real-world applications, limitations, future direction, and sources. The tool saves the report to report.md, and the agent logs that the research completed successfully.

After finishing the task, the pod enters a sleep loop and remains running without restarts. The workflow is complete: the agent finished its work, PostgreSQL stores the checkpoint history, and the Kubernetes Deployment stays healthy.

Verifying state persistence 

Once the agent finishes, the complete research report appears in the pod logs under the write_report tool call. The report includes an executive summary, recent developments, key features, real-world applications, limitations, future direction, and the sources used during the research.

To confirm that the agent saved its state after each step, query the checkpoints table directly using the PostgreSQL connection string from the credentials you retrieved earlier:

python3 -c "
import psycopg
conn = psycopg.connect('postgresql://civo:password@host:5432/postgres?sslmode=require')
cur = conn.cursor()
cur.execute('SELECT thread_id, checkpoint_id FROM checkpoints ORDER BY checkpoint_id DESC LIMIT 10;')
for row in cur.fetchall():
print(row)
conn.close()
"
checkpoint query output

Checkpoint query output

The output shows one row for each agent step. Every search, fetch, and reasoning decision was saved to PostgreSQL as a checkpoint. If the pod had been stopped during the run, Kubernetes would restart it, and the agent would load the latest checkpoint and continue from where it stopped without repeating completed work.

That is the difference between a stateless script and an agent workflow that can maintain progress.

What's next

The deployment in this tutorial covers the core of what you need to run a persistent agent on Kubernetes. From here, you can extend it in several ways:

  • Run scheduled tasks by replacing the Deployment with a Kubernetes CronJob. The agent can run automatically, store each run under a new thread ID in PostgreSQL, and exit when finished.
  • Add an HTTP endpoint by wrapping the agent with FastAPI and exposing it through a Civo LoadBalancer. Users can send a research prompt, receive a job ID, and check the result later.
  • Run multiple research tasks at once by scaling the Deployment with multiple replicas, each using a different THREAD_ID. PostgreSQL handles the state for each agent separately.
  • Use a self-hosted model by adding a GPU node pool and connecting the agent to a vLLM inference server. The agent code stays the same; only the model endpoint changes.
  • Add tracing and monitoring by connecting to LangSmith. Agent steps, tool calls, and workflow progress can be tracked by adding the required environment variable.

These changes do not require modifying the core agent logic. The graph, tools, and checkpointing system remain the same. Only the infrastructure around the agent changes.

Key takeaways

The agent state survives pod restarts because it is stored in PostgreSQL instead of process memory. Every search, page fetch, and reasoning step is saved after it completes via LangGraph checkpointing. If the pod is stopped during a run, Kubernetes restarts it, and the agent loads the latest checkpoint and continues from where it stopped.

CPU-only compute is enough for this setup. relaxAI handles the LLM requests through an OpenAI-compatible API, so no GPU node is required. The entire stack runs on a single small Kubernetes node, keeping the setup simple and the cost low.

Everything is managed through one Civo account. The Kubernetes cluster, Managed PostgreSQL instance, and networking are provisioned from the same CLI and appear on the same bill. There is no need to manage multiple providers or separate database infrastructure.

Mostafa Ibrahim
Mostafa Ibrahim

Software Engineer at GoCardless

Mostafa Ibrahim is a software engineer and technical writer specializing in developer-focused content for SaaS and AI platforms. He currently works as a Software Engineer at GoCardless, contributing to production systems and scalable payment infrastructure.

Alongside his engineering work, Mostafa has written more than 200 technical articles reaching over 500,000 readers. His content covers topics including Kubernetes deployments, AI infrastructure, authentication systems, and retrieval-augmented generation (RAG) architectures.

View author profile