Module 4: The System's Data Model
6. The local stack for state, at zero cost
Description
By the end of this lesson you'll have the ledger and the dedup store you designed living in a real database —the Postgres the Self-Hosted AI Starter Kit v2 already ships with— running on your own machine, at no cost. You're going to know which services the Starter Kit brings and what each one is for, how n8n already uses that Postgres internally, how to create a Postgres credential in n8n and point it at the packaged Postgres, and how to run the SQL that creates your tables. You're going to have the visual signals confirming each step worked, so you're never left wondering whether you made progress or something broke.
This matters because up to now you designed the mechanism in the abstract —tables, statements, parameter placeholders— and a design that isn't plugged into anything deduplicates nothing. This is the lesson that turns the blueprint into a working installation. And it does it the most accessible way possible: you don't set up or hire a database, because you already have one running. The Starter Kit spun it up alongside n8n from the first minute; all that's missing is pointing your nodes at it.
Connection to the module: lessons 3 and 5 designed the run_ledger and processed_orders tables and the statements using them; this one gives them a physical home. Lesson 7 is going to use two other services from the same Starter Kit —Qdrant and Ollama— for idempotent RAG ingestion, so here you also get to meet them in passing. And lesson 8's project runs entirely on what you set up here. It's the module's most procedural lesson: less new concept, more "do this on your machine and confirm it worked."
Let's plug in the state
Before touching anything, some reassurance. We're going to do something very satisfying: take the mechanism you designed in the previous lessons and watch it come alive on a real database, on your own machine. This lesson is about installation and configuration, and that kind of task is notoriously frustrating because every computer is its own world: a different path, a busy port, a version that renamed a button. I'm going to give you the happy path —what works for most people— and, whenever something can vary, I'm going to tell you honestly so you know where to look instead of feeling like you did something wrong. If you run into a really unusual error not in this guide, that's expected: use the official documentation, your AI assistant, or the n8n community. Environment failure isn't your fault; it's the nature of setup.
And a dates honesty note, because this content ages faster than the rest: as of this guide's writing —July 2026— the Starter Kit is brought up with docker compose --profile cpu up, n8n ends up at http://localhost:5678, and the services are named the way you're about to see below. Names, commands, or paths will probably change over time. When something doesn't match what you see on your screen, the source of truth is the Starter Kit's official repository and n8n's documentation, not this guide. I flag the volatile facts on purpose so you know which ones to verify.
The Starter Kit v2: four services already running
The Self-Hosted AI Starter Kit is an official n8n template: a docker-compose.yml file that, with a single command, spins up a complete AI automation environment on your machine. The keyword is together: you don't install four things separately, you bring them all up at once. Per the official documentation, the kit includes:
| Service | What it is, simply | What you use it for in this module |
|---|---|---|
| n8n | The workflow platform you already know | Where order-triage and every flow runs |
| PostgreSQL | A robust, mature relational database | The home for run_ledger and processed_orders |
| Qdrant | A vector store for meaning-based search | Idempotent RAG ingestion (lesson 7) |
| Ollama | An engine for running language models on your machine | Generates embeddings with no paid API (lesson 7) |
Docker, if it isn't fresh in your mind, is the tool that packages each of these services into a "container" —an isolated little box with everything the service needs to run— and makes them coexist on the same machine without stepping on each other. Think of it as four appliances that each arrive in their own box, already assembled, and you just have to plug them into the same wall. docker compose is the switch that turns them all on at once.
Of those four services, this module's protagonist is PostgreSQL, which is where you're going to set up the ledger and the dedup store. The other two AI services —Qdrant and Ollama— come into play in lesson 7, so it's worth introducing them in passing. Qdrant stores "vectors": numeric representations of a text's meaning, which let you search by semantic similarity instead of exact words —it's the engine behind a RAG system—. Ollama runs language models on your own machine, with no call to any paid API; in lesson 7 you use it to turn documents into those vectors (the "embeddings"). For now, it's enough to know they're there, powered on, waiting for their turn.
A note on the startup command: the --profile cpu you see below brings up the stack using only the processor. If your machine has a compatible graphics card, there are profiles that take advantage of it (for example, one for Nvidia GPUs), which speeds up Ollama. For this module, the CPU profile is more than enough —we're going to use Postgres, which needs no GPU—; the GPU profile is an optimization for lesson 7 if your machine has one. Choose whichever fits your machine, and don't worry if you can only use CPU: it works the same, just a bit slower on the embeddings part.
There's a detail that's the key to this whole lesson, and it's worth stating clearly: n8n, when you spin it up with the Starter Kit, already stores its own data in that Postgres. Its workflows, its credentials, its execution history —all of that lives in the database the kit brought up. In other words, Postgres isn't an extra you have to set up: it's there, powered on, with n8n talking to it from the first minute. All you're going to do that's new is create a couple of tables of your own in that same database and point some nodes at them.
Worked example: bringing up the kit and confirming Postgres is alive
If you don't have the kit running yet, here's the happy path. As of this guide:
# In a terminal, inside the cloned Starter Kit folder:
docker compose --profile cpu up
That command turns on all four services. The first time it takes a while, because Docker downloads the images —each appliance's "box"—. You're going to see a lot of lines of text scrolling by. That's fine. It's normal and expected; it isn't an error, it's Docker bringing what it needs.
What you're going to see and what it means. When startup finishes, among all those lines you're going to make out messages from each service starting up. The signal confirming n8n is ready is that you can open http://localhost:5678 in your browser and see n8n's interface. If you see it, n8n is alive and —what matters to us— so is Postgres, because n8n doesn't start without its database. In other words: if you got into n8n, your Postgres is already running. Excellent, that's the checkpoint we needed.
If something fails here —a busy port, Docker not installed, a profile that got renamed— it's exactly the kind of environment hiccup I mentioned. The Starter Kit's official repository has the current instructions and common issues; that's your first stop, not this guide.
How n8n talks to Postgres: the credential
For a Postgres node to be able to read and write your tables, n8n needs to know how to connect to the database: what address it's at, what port, with what user and password, and to which database. All of that together is a Postgres credential in n8n. You create it once, n8n stores it encrypted, and every one of your Postgres nodes reuses it.
There's a concept confusing a lot of people at first, and it's worth calmly taking apart: where n8n connects from. Since n8n runs inside a Docker container, and Postgres runs in another container from the same kit, n8n doesn't look for Postgres on "your computer" (localhost), but by the service name Docker gave it within the kit's network. In the Starter Kit's docker-compose.yml, that service is usually called postgres. So, from n8n's point of view, the database's address isn't localhost but postgres.
Think of it this way: inside an office building (Docker's network), you don't call a coworker by dialing the street number; you dial their internal extension. postgres is the database's internal extension inside the Starter Kit's building. This is the detail causing the most stumbles: people put localhost because that's what they'd use from their own machine, and from inside n8n's container, localhost refers to n8n's own container, not the database. That's why postgres.
The concrete values —user, password, database name— don't make them up: they live in the Starter Kit's .env file, in variables that as of this guide's writing are named something like POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB. Open that file and use exactly what it says. Those variable names can change between kit versions; the .env file is your installation's source of truth.
Worked example: creating the credential step by step
Let's create it. In n8n's interface:
# Create a Postgres credential in n8n
1. Open a workflow and add a Postgres node (or go to the Credentials section).
2. In the node, in the Credential field, choose "Create New Credential".
3. Fill in the fields with your .env values:
Host: postgres ← the Docker service name, NOT localhost
Database: <whatever POSTGRES_DB says in your .env>
User: <whatever POSTGRES_USER says in your .env>
Password: <whatever POSTGRES_PASSWORD says in your .env>
Port: 5432 ← the standard Postgres port
SSL: normally off for the kit local Postgres
4. Save the credential.
Let's go over the why for each field, because "fill this in" on its own teaches nothing:
- Host:
postgres— the database's internal extension within Docker's network. It's the field most people get wrong by puttinglocalhost; from n8n's container, the database is calledpostgres. - Database — the specific database's name within Postgres. A Postgres server can have several databases; this is the one the kit created, whose name is in
POSTGRES_DB. - User and Password — the access credentials, exactly as the kit defined them in
.env. Don't guess them; copy them from there. - Port:
5432— the port Postgres listens on by default. It's a standard value and this is almost always it. - SSL — for a local database within your own machine, it's normally off. In production, over a real network, this changes; but that's the operations guide's topic, not here.
What you're going to see and what it means. Many n8n versions have a "Test connection" button when you save the credential. If a success message shows up —usually in green, something like "Connection successful"—, it means n8n found your Postgres and could get in with those details. That's the checkpoint. If instead you see a connection error, suspect number one is the Host (did you put postgres and not localhost?), and number two is the user or password (do they exactly match the .env?). Great if it turned green; you now have the channel open between n8n and the database.
Creating your tables in that database
With the credential working, the last setup step is creating the two tables you designed. You do this once. The most direct way from n8n is a Postgres node in Execute Query operation running the CREATE TABLE.
# Node: Postgres — "Setup — create tables" (operation: Execute Query)
# Runs ONCE, when setting up the system. Afterward you can delete or disable it.
Query:
CREATE TABLE IF NOT EXISTS run_ledger (
id BIGSERIAL PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
order_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS processed_orders (
idempotency_key TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Remember from lesson 3 that IF NOT EXISTS is your safety net: if you run this twice, the second time doesn't fail or delete anything, it simply does nothing. That lets you run it fearlessly even if you don't remember whether you already did.
What you're going to see and what it means. When you run the node, if no red error shows up, the tables got created (or already existed). To confirm it with your own eyes, run in another Execute Query node a query that lists them or counts their rows:
-- Confirm the tables exist and are empty at the start
SELECT count(*) AS ledger_rows FROM run_ledger;
SELECT count(*) AS dedup_rows FROM processed_orders;
If that query returns 0 for each with no error, congratulations: the tables exist, they're empty, and ready to receive their first entry and their first key. That's the correct startup state. You just went from "I designed some tables in the abstract" to "I have two real tables in a real database running on my machine." Not bad at all.
A reasonable doubt that comes up here: if I shut down Docker or restart the computer, do I lose the tables and their data? The short, reassuring answer is no —as long as the stack is configured the way it comes out of the box—. The Starter Kit stores Postgres's data in a Docker "volume": a storage space that survives the containers being turned off and back on. Think of it as the appliance's hard drive: even if you unplug the device, what it stored is still there when you plug it back in. This is exactly the durability lesson 2 asked for and that Static Data didn't guarantee: your system's truth doesn't live in an execution's volatile memory, it lives on disk, and it stays there. (Seriously managing those volumes —backups, migrations— is the operations guide's topic; here it's enough to know that, by default, your data persists across restarts.)
Looking at the state with your own eyes
Now that the tables live in a real database, you have something Static Data never gave you: you can peek inside and see what's there. This capability isn't a luxury; it's the difference between operating blind and operating on data, and it's one of the reasons you chose a database in lesson 2.
The most convenient way, without leaving n8n, is a Postgres node in Select or Execute Query operation running a read query. For example, to see the ledger's latest entries:
-- The 10 most recent ledger entries
SELECT order_id, status, result, created_at, updated_at
FROM run_ledger
ORDER BY created_at DESC
LIMIT 10;
Or to check which keys the dedup store remembers:
-- The keys the dedup store has already processed, most recent first
SELECT order_id, idempotency_key, processed_at
FROM processed_orders
ORDER BY processed_at DESC
LIMIT 10;
What you're going to see and what it means. Before running any flow, these queries return zero rows: the tables are empty, and that's correct at startup. After order-triage processes its first order, the same query is going to show one row in each table. That change —from empty to having data— is the visible proof that your system is writing its truth where it should. When in lesson 8 you fire the webhook twice, you're going to come back to these queries to demonstrate there's a single row and a single charge, not two. The database stops being a closed box and becomes a window.
If you'd rather use a dedicated tool to explore the database —with visual tables, instead of loose queries— there are widely-used graphical Postgres clients. You don't need them for this module, but if you're comfortable with them, point them at the same Postgres (remembering that from your machine, outside Docker, the address is usually localhost with whatever port the kit exposes, the opposite of from inside n8n's container). That nuance —postgres from inside the container, localhost from your machine— confuses everyone at first; being clear on it saves you a good chunk of frustration. If you get tangled up, the Starter Kit's documentation and your AI assistant are good places to ask about your version's exact configuration.
Your own table, or a separate database: the honest decision
A criterion note is worth having, because it touches a reasonable doubt. You're creating your tables in the same database where n8n stores its own things (workflows, executions). Is it fine to mix your tables with n8n's?
For learning and for a personal project, it's perfectly fine and it's the simplest thing: a single database, your tables coexisting with n8n's, each with its own name. Postgres keeps them separate with no problem; there's no risk of your run_ledger interfering with n8n's internal tables as long as you don't touch them.
For a system that's going somewhere serious, the cleaner practice is giving your application its own database (or at least its own schema) separate from the one n8n uses for itself, so you can back up, migrate, or inspect your data without getting tangled up with n8n's internals. That's done by creating a separate database or schema and pointing your credential there. We don't develop it in detail because it crosses into the operations guide —how to administer Postgres in production—; here it's enough that you know the option exists and why. Start simple, with the database you already have; separate when the system calls for it.
Common mistakes
Putting localhost in the credential's Host (practical, the classic). What happens: the credential gets configured with Host localhost, the connection test fails, and it's not clear why if "Postgres is running on my machine." Why it happens: n8n runs inside a container, and from there localhost is n8n's own container, not the machine or Postgres's container. How to spot it: if your Host is localhost and the connection fails within the Starter Kit, this is almost certainly it. How to fix it: use the Docker service name, which in the Starter Kit is usually postgres. Confirm the exact name in the docker-compose.yml.
Making up the user or password instead of reading them from .env (practical). What happens: "example" values get put in, or ones someone thinks they remember, and the connection bounces back with an authentication error. Why it happens: every installation has its own credentials, defined in the kit's .env. How to spot it: if the error is authentication (user/password) and not connection, this is it. How to fix it: open the Starter Kit's .env and copy exactly the POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB values.
Believing you have to install Postgres separately (conceptual). What happens: someone postpones the whole module to "install a database first," not realizing they already have one running. Why it happens: it isn't obvious the Starter Kit ships with Postgres included. How to spot it: if your blocker is "I don't have a database" and you already brought up the Starter Kit, you already have one. How to fix it: if you opened n8n at localhost:5678, your Postgres is alive; all that's missing is pointing the credential at it.
Forgetting IF NOT EXISTS and breaking on re-run (practical). What happens: the tables get created with a plain CREATE TABLE, and running the setup again —out of habit or by mistake— fails the node with "the table already exists." Why it happens: without IF NOT EXISTS, CREATE TABLE on an existing table is an error. How to spot it: if your setup node fails the second time you run it, this is it. How to fix it: use CREATE TABLE IF NOT EXISTS, which makes the setup repeatable with no consequences.
Confusing your system's state with n8n's internal data (conceptual). What happens: someone tries to read or modify n8n's internal tables to "see the executions," instead of using their own tables. Why it happens: since everything lives in the same Postgres, the boundary blurs. How to spot it: if you're querying tables you didn't create (with n8n's internal names), you've left your territory. How to fix it: for your system's truth use your own tables (run_ledger, processed_orders); n8n's internal tables are its own and shouldn't be touched. If you want stable execution data, the ledger you write is the source, not n8n's internal database.
Deleting the volume and accidentally losing the data (practical). What happens: cleaning up Docker with commands that remove volumes makes the tables and their content disappear, and it's not clear why. Why it happens: Postgres's data lives in a Docker volume; if you delete the volume, you delete the data. A normal docker compose down stops the containers but keeps the volume; adding the option that removes volumes does delete them. How to spot it: if your tables "disappeared" right after a Docker cleanup, this is it. How to fix it: to shut down without losing data, shut down without removing volumes; and if the system matters, have a backup —how to do that is the operations guide's topic, but knowing the data lives in the volume already saves you the scare—.
Exercises
Exercise 1 — Diagnose the connection. For each symptom when testing the credential, say what the most likely cause is and how you'd confirm it.
(a) Connection error (can't reach the database), with Host set to localhost.
(b) Connects, but authentication error.
(c) Connects and authenticates, but creating the table says it already exists.
See solution
(a) Wrong Host. Within the Starter Kit, n8n reaches Postgres by the service name (postgres), not by localhost. Confirm it by changing the Host to postgres and testing again; if it connects, that was it. To be sure of the name, check it in the docker-compose.yml.
(b) User or password not matching the .env. The connection reached the database (a good sign the Host is right), but the credentials don't line up. Confirm it by opening the kit's .env and comparing character by character with what you entered.
(c) Not a real error: the table was already created. If you used CREATE TABLE with no IF NOT EXISTS and had already created it, this is expected. Confirm it by querying the table (SELECT count(*) FROM run_ledger): if it responds, it exists and everything's fine; just add IF NOT EXISTS so the setup is repeatable.
Why this works: notice the three symptoms are told apart by where they fail. Fails to connect → Host. Connects but doesn't authenticate → user/password. Authenticates but the table exists → a non-problem. Diagnosing by the stage where something breaks is half the work of solving it.
Exercise 2 — Explain postgres vs. localhost. A coworker insists their Host should be localhost "because Postgres is on my computer." Write the explanation you'd give them to understand why, from n8n, the address is postgres.
See solution
A reference version:
You're right that Postgres is on your computer, but from n8n's point of view things look different. n8n doesn't run "on your computer" plainly: it runs inside a Docker container, which is like a small, isolated machine within yours. And Postgres runs in another container. When n8n says
localhost, it's referring to its own container —to itself—, not your machine or Postgres's container. There's no database there, that's why it fails.Docker connects the Starter Kit's containers on an internal network, and within that network every service has a name. The database's is
postgres(it's in thedocker-compose.yml). It's like an internal phone extension in the building: to call the database you dialpostgres, not the street address. That's why the Host ispostgresand notlocalhost.
Why this works: the explanation doesn't say "do it because I said so"; it gives the mental model —isolated containers, internal network, service names— that makes the rule make sense and lets the person reason through the next similar case on their own.
Exercise 3 — Verify your setup. Without looking at the lesson, list the checkpoints confirming, one by one, that your stack is ready for lesson 8's project, and what visual signal you expect at each one.
See solution
A reference list:
- The Starter Kit is running → you can open
http://localhost:5678and see n8n's interface. (If you get into n8n, Postgres is also alive, because n8n doesn't start without it.) - The Postgres credential works → the connection test succeeds ("Connection successful" or similar), with Host
postgresand the.envvalues. - The tables exist → a
SELECT count(*)onrun_ledgerandprocessed_ordersresponds0with no error.
With those three green, you have the system's state living in a real database, at zero cost, ready for the project to use. A fourth, optional but reassuring checkpoint: restart Docker and run the SELECT count(*) again; if the tables are still there, you've confirmed with your own eyes that the data persists across restarts, which is exactly the durability you were after.
Why this works: every checkpoint has a concrete visual signal, not a "it should work." That's the difference between believing you set something up and knowing you set it up. When something breaks down the line, you're going to be able to come back to these three points and find which one fell over.
Summary and next step
In this lesson you plugged the mechanism you designed into a real database. The Self-Hosted AI Starter Kit v2 brings up, with a single command, four services —n8n, PostgreSQL, Qdrant, and Ollama— on your machine, at zero cost, and n8n already uses that Postgres internally from the first minute. So there was no need to set up the database: it was already there.
The steps were three, each with its confirmation signal: bring up the kit (checkpoint: you get into localhost:5678), create the Postgres credential pointing at Host postgres —not localhost, because n8n runs in a container and reaches the database by its service name— with the .env file's values (checkpoint: connection test green), and create the tables run_ledger and processed_orders with CREATE TABLE IF NOT EXISTS (checkpoint: a SELECT count(*) responding 0). The most common mistake of all is putting localhost in the Host; the second, making up the user and password instead of reading them from .env.
And you gained two things Static Data never gave you and that lesson 2 asked for: a window to peek at the state with a SELECT, and real durability, because the data lives in a Docker volume that survives restarts. Your system's truth is no longer a sticky note; it's a table on disk you can query whenever you want.
Before moving on you should be able to: name the Starter Kit's four services; explain why the Host is postgres and not localhost; and list your setup's three checkpoints.
Lesson 7 uses the other two services you just met —Qdrant and Ollama— to bring idempotency into new territory: RAG ingestion. You're going to see that not re-embedding a document you already processed is exactly the same deduplication pattern from this module, with a twist: the key isn't an order_id, it's a hash of the file's content. The same turnstile, a different door. And along the way you're going to confirm what you learned isn't a trick for charges, but a general pattern serving anywhere there's an effect you don't want to repeat.
Resources
- Deploy with the AI starter kit — n8n Docs — the official guide: what services it brings, how to bring it up with Docker, and the current command. Your source of truth when something doesn't match this lesson.
- self-hosted-ai-starter-kit — GitHub — the repository with the real
docker-compose.ymland the.env.example. Confirm there the Postgres service name and your version's environment variable names. - Postgres credentials — n8n Docs — the Postgres credential's fields in n8n (Host, Database, User, Password, Port, SSL) and what each expects.
- Postgres node — n8n Docs — the Execute Query operation you use to run the
CREATE TABLEand the verification queries. - Docker Compose — overview — what
docker composeis and how it brings up several services together, in case you want to understand the tool powering on the stack.