Everything in Torollo is a node: a draggable card on the canvas that maps 1:1 to a real Docker container running on the contributor's machine. A "PostgreSQL", a "Redis", a "NAT Gateway" — all nodes.
This guide walks you through adding a brand-new node type, end to end. By the end you'll have a node you can drag from the library, drop on the canvas, start/stop, and (optionally) inspect through a modal.
We deliberately keep this codebase modular, readable, and boring — the kind of code a newcomer can follow on their first read. This guide is as much about how we write code as it is about where the code goes. Please read the Code Standards section before opening your PR.
- The mental model
- The wiring map
- Worked example: adding a "Memcached" cache node
- Code standards
- Testing & running locally
- Pull request checklist
Before writing code, understand the lifecycle of a node. Nothing here is magic:
Library card Drop on canvas Backend Docker
┌────────────┐ drag ┌──────────────┐ POST ┌──────────────┐ run ┌─────────────┐
│ NodeLibrary│ ─────► │ CanvasPage │ ─────► │ContainerMgr │ ────► │ real │
│ (palette) │ │ (drop + name)│ │(image+config)│ │ container │
└────────────┘ └──────────────┘ └──────────────┘ └─────────────┘
│ │
│ render via nodeTypes │ poll status
▼ ▼
┌──────────────┐ ┌─────────────┐
│ <Node>.tsx │ ◄───── state (running/…) ── │ container │
│ (BaseNode) │ │ info │
└──────────────┘ └─────────────┘
│ Inspect
▼
┌──────────────┐
│ <Node>Modal │ (optional: shell / explorer)
└──────────────┘
Two rules follow directly from this diagram, and they drive the whole design:
- A node is identified everywhere by a single lowercase string
type("redis","postgres","nat", …). That string is the contract between the palette, the canvas, the API, and the container labels. Pick it once, use it verbatim everywhere. - The backend owns Docker; the frontend owns the canvas. They only ever talk through
the
typestring and the container info the API returns. Keep that boundary clean.
Adding a node is mostly thin wiring across well-defined seams — not deep new logic. Here is the complete list. Nothing else needs to change.
| File | What you add |
|---|---|
backend/src/infrastructure/docker/ContainerManager.ts |
The image tag, an ensure<X>Image() pull helper, the image-selection branch, any custom container options, and the port mapping. This is the only file that talks to Docker. |
backend/src/modules/containers/services/containerService.ts |
(Optional) An explorer/query method, if your node has an inspector modal (like getRedisExplorer). |
Every image referenced in backend/src/infrastructure/docker/nodeTypes.ts — and any custom
image a user supplies — must include iptables and iproute2. This is not optional:
the backend enforces Security Groups by exec-ing iptables inside the container, and
programs routing (NAT gateways, VPC routes) with ip route. Containers are started with
CAP_NET_ADMIN, so only the binaries are required — the image does not need to run
privileged.
If the tools are missing, the failure is easy to miss:
- No
iptables→ firewall configuration is silently skipped (a warning in the backend logs); the node runs with no Security Group enforcement at all. - No
ip(iproute2) → routing setup for the node is skipped the same way, so NAT and VPC routes won't apply.
The published derssa/backend-lab-*:v1 images already ship both tools. If you build your
own image, either base it on one of those, or add the packages in your Dockerfile.
The backend only checks that a custom image reference is well-formed (standard
[registry/]name[:tag][@digest] format) — the image itself runs locally under your own
responsibility, like any container you'd start by hand:
# Debian/Ubuntu-based images
RUN apt-get update && apt-get install -y iptables iproute2 && rm -rf /var/lib/apt/lists/*
# Alpine-based images
RUN apk add --no-cache iptables iproute2To verify an image is correctly configured, run the integration suite (requires a running Docker daemon):
cd backend && npm run test:integration| File | What you add |
|---|---|
frontend/src/features/nodes/<X>Node/<X>Node.tsx |
The node card. Reuses BaseNode — usually ~40 lines. |
frontend/src/features/nodes/<X>Node/<X>Modal.tsx |
(Optional) The inspector modal. |
frontend/src/pages/CanvasPage/components/NodeLibrary.tsx |
One entry in the palette (category, type, icon, description). |
frontend/src/pages/CanvasPage/CanvasPage.tsx |
Register in nodeTypes; wire onInspect + modal state (only if you have a modal); add the drop-dialog placeholder text. |
frontend/src/shared/types/index.ts |
Add your type to the ContainerData['type'] union. |
Tip: The single fastest way to add a node is to copy the Redis node and find-and-replace. Redis is our cleanest, most recent reference and it exercises every layer (card, modal, explorer, dedicated image). We use it as the example below.
We'll add a Memcached node — an in-memory cache, conceptually a sibling of Redis. Its
type string is memcached and its default port is 11211. Follow along; swap in your
own node's details as you go.
Everything Docker-related lives in one file: ContainerManager.ts. That is by design —
the frontend never needs to know an image tag exists.
Alongside the other image constants (near the top of the class):
private static readonly MEMCACHED_IMAGE_TAG = 'derssa/backend-lab-memcached:v1';Each image has a small helper that pulls it from Docker Hub the first time it's needed.
Copy ensureRedisImage() verbatim and rename it — the shape is identical, only the tag and
the log label change:
/**
* Ensures that the Memcached image exists locally.
*/
private static async ensureMemcachedImage(): Promise<void> {
const images = await docker.listImages();
const hasImage = images.some(img =>
img.RepoTags && img.RepoTags.includes(this.MEMCACHED_IMAGE_TAG)
);
if (!hasImage) {
console.log('Pulling Memcached image (first time only)...');
await new Promise<void>((resolve, reject) => {
docker.pull(this.MEMCACHED_IMAGE_TAG, {}, (err, stream) => {
if (err) return reject(err);
if (!stream) return reject(new Error('Pull stream is undefined'));
docker.modem.followProgress(
stream,
(errFinished) => {
if (errFinished) return reject(errFinished);
resolve();
},
(event) => {
if (event.status) {
const progress = event.progress ? ` ${event.progress}` : '';
console.log(`[Docker Hub Pull - Memcached] ${event.status}${progress}`);
}
}
);
});
});
}
}In createContainer, add your branch to the type checks and the image selection. Follow
the exact pattern already there:
const isRedis = type === 'redis';
const isMemcached = type === 'memcached'; // ← add
// ...
let image = this.UBUNTU_IMAGE_TAG;
if (customImage) image = customImage;
else if (isPostgres) image = this.POSTGRES_IMAGE_TAG;
else if (isRedis) image = this.REDIS_IMAGE_TAG;
else if (isMemcached) image = this.MEMCACHED_IMAGE_TAG; // ← add
// ...
} else if (isRedis) {
await this.ensureRedisImage();
} else if (isMemcached) { // ← add
await this.ensureMemcachedImage();
}If your node needs special container options (env vars, entrypoint, published ports), add
a branch in the if (isPostgres) { … } else if (…) block. Memcached starts on its default
command with no extra config, exactly like Redis, so no branch is needed. Only add
configuration you actually need — resist copy-pasting Postgres' env vars "just in case".
listContainersByProject reports the published port back to the UI. Add your private port
to the match list:
const matchedRedis = c.Ports.find(p => p.PrivatePort === 6379);
const matchedMemcached = c.Ports.find(p => p.PrivatePort === 11211); // ← add
const matchedPort = matchedPostgres || matchedMongo || matchedRedis || matchedMemcached || matchedNginx;If your node has an interactive modal (a shell, an explorer), add:
- A low-level command runner in
ContainerManager.ts— copyexecuteRedisCommandand change the CLI binary (e.g. Redis usesredis-cli; yours might use a different client). - A service method in
containerService.ts— e.g.getMemcachedExplorer/executeMemcachedQuery— that shapes the raw output into what the modal renders.
Keep the low-level "run a command in the container" concern in ContainerManager and the
"turn output into UI data" concern in ContainerService. Don't mix them.
That's the entire backend. Type unions: update the ContainerInfo['type'] union in
ContainerManager.ts so the compiler keeps you honest.
Create frontend/src/features/nodes/MemcachedNode/MemcachedNode.tsx. Do not hand-roll
a card — compose BaseNode, which owns all the shared chrome (status dot, start/stop/delete
buttons, security-group shield, handles). A node component only declares what's different:
its icon, its accent color, its subtitle, and its primary action.
import { Database, Search } from 'lucide-react';
import BaseNode from '../components/BaseNode';
import styles from '../ServiceNode.module.css';
interface MemcachedNodeProps {
data: {
id: string;
name: string;
state?: string;
ip?: string;
onSecurityGroupOpen?: (id: string, name: string) => void;
onInspect: (id: string, name: string) => void;
onStop: (id: string) => void;
onStart: (id: string) => void;
onDelete: (id: string) => void;
};
}
export default function MemcachedNode({ data }: MemcachedNodeProps) {
const isRunning = data.state === 'running';
return (
<BaseNode
id={data.id}
name={data.name}
isRunning={isRunning}
icon={<Database size={18} color={isRunning ? '#0EA5E9' : '#6B7280'} />}
customBorder={isRunning ? '1px solid #0EA5E9' : undefined}
subtitle={
<>
<span className={styles.label}>IP/Port:</span>
<span className={styles.value} style={{ color: data.ip ? '#10B981' : undefined }}>
{data.ip ? `${data.ip}:11211` : '11211'}
</span>
</>
}
onStart={data.onStart}
onStop={data.onStop}
onDelete={data.onDelete}
onSecurityGroupOpen={data.onSecurityGroupOpen}
primaryAction={{
label: 'Inspect',
icon: <Search size={14} />,
color: '#0EA5E9',
onClick: data.onInspect,
title: 'Inspect Memcached',
}}
/>
);
}Notice how little this file says. That's the goal: the shared behavior lives in
BaseNode, the node only expresses its identity. If you find yourself copying layout or button markup into a node, stop — it belongs inBaseNodeinstead.
In CanvasPage.tsx, import your node and add it to the nodeTypes map:
import MemcachedNode from '../../features/nodes/MemcachedNode/MemcachedNode';
// ...
const nodeTypes = useMemo(() => ({
ubuntu: UbuntuNode,
redis: RedisNode,
memcached: MemcachedNode, // ← add
// ...
}), []);In NodeLibrary.tsx, add an entry to the relevant category. The type must match the
backend/nodeTypes string exactly:
{
type: 'memcached',
name: 'Memcached',
desc: 'In-memory key-value cache',
icon: <Database size={18} color="#0EA5E9" />,
collapsedIcon: <Database size={20} color="#0EA5E9" />
}In frontend/src/shared/types/index.ts, extend the union so the rest of the app type-checks:
type?: 'ubuntu' | 'postgres' | 'redis' | 'memcached' | 'nat' | 'loadbalancer' | /* … */;When a node is dropped, a small dialog asks for a name. Add your node's placeholder/prefix
alongside the existing type === 'redis' ? … branches in CanvasPage.tsx (search for
redis-1). This is cosmetic but expected for consistency.
If you built a backend explorer (Step 5), create
frontend/src/features/nodes/MemcachedNode/MemcachedModal.tsx (model it on RedisModal),
then wire three small things in CanvasPage.tsx, following the Redis path exactly:
// 1. state
const [inspectingMemcached, setInspectingMemcached] =
useState<{ id: string; name: string } | null>(null);
// 2. inside onInspect
} else if (nodeType === 'memcached') {
setInspectingMemcached({ id, name });
}
// 3. render near the other modals
{inspectingMemcached && (
<MemcachedModal
containerId={inspectingMemcached.id}
nodeName={inspectingMemcached.name}
onClose={() => setInspectingMemcached(null)}
/>
)}If your node has no inspector (e.g. a pure networking primitive), skip Steps 5 and 11
entirely and don't pass a primaryAction to BaseNode.
Done. Update the README's "Supported Infrastructure Nodes" list and you're ready to open a PR.
These aren't bureaucracy — they're why adding a node above was mostly copy-and-rename instead of a rewrite. Please hold your contribution to the same bar.
1. One node, one folder, one responsibility.
Everything for a node lives under features/nodes/<X>Node/. The card renders; the modal
inspects; a data/ file holds static content (like the cheat sheets). Don't scatter a
node's code across the tree.
2. Compose, don't duplicate.
Shared behavior belongs in a shared place — BaseNode for card chrome, ContainerManager
for Docker, ContainerService for output shaping. If you're pasting the same block into a
second file, that's a signal it should be lifted into the shared layer. Every node card in
this repo is small because the common parts were factored out. Keep it that way.
3. Respect the layers. Data flows in one direction:
NodeLibrary → CanvasPage → useContainers (API) → controller → ContainerService → ContainerManager → Docker
The frontend never imports Docker concepts; the backend never imports React. The only thing
crossing the boundary is the type string and the container info returned by the API. If a
change makes you reach across a layer, reconsider the approach.
4. The type string is a contract. It appears in the palette, nodeTypes, the API
body, the Docker label (akal.node.type), and the type unions. It is always lowercase, has
no spaces, and is identical in every location. Choose it deliberately.
5. Types are not optional. This is a strict TypeScript codebase. When you add a node,
extend both union types (ContainerData on the frontend, ContainerInfo on the
backend). Let the compiler find every place you forgot — that's its job.
6. Add only the configuration you need. Postgres sets env vars and an entrypoint because Postgres requires them. Redis and Memcached don't, so they add nothing. Don't copy configuration defensively — every line you add is a line the next contributor must understand.
7. Comment the why, not the what. Good code shows what it does. Save comments for
decisions that would otherwise be surprising — see the seeding comment in getRedisExplorer
explaining why the marker lives in logical DB 1. Match the tone and density of the
surrounding file.
8. Naming mirrors the domain. MemcachedNode, ensureMemcachedImage,
getMemcachedExplorer. Predictable names are what make "copy the Redis node and
find-and-replace" a viable workflow. Keep names boring and parallel.
9. Conventional Commits. e.g. feat: add Memcached cache node. See
CONTRIBUTING.md.
See CONTRIBUTING.md for full setup. In short, with Docker running:
# Terminal 1 — backend (port 23233)
cd backend && npm run dev
# Terminal 2 — frontend (port 23232)
cd frontend && npm run devThen manually verify the full lifecycle of your node:
- It appears in the correct library category, with its icon and description.
- Dragging it onto the canvas opens the name dialog and creates a real container
(confirm with
docker ps). - The card reflects state correctly (Offline → Start → Online, and Stop).
- The IP/port subtitle shows once running.
- The Inspect modal opens and works (if applicable).
- Deleting the node removes the container (confirm with
docker ps -a).
If your node adds backend logic worth testing, add coverage next to the existing tests
(e.g. containerController.test.ts, virtualNetworkMapper.test.ts) and make sure the
suite passes:
npm test --prefix backendFinally, make sure everything builds cleanly — CI runs this too:
npm run build --prefix backend
npm run build --prefix frontendCopy this into your PR description and tick it off:
-
typestring is lowercase and identical across palette,nodeTypes, API, labels, and unions. - Backend: image tag,
ensure<X>Image(), image selection branch, port mapping,ContainerInfounion. - If you added or changed a node image: it includes
iptablesandiproute2(see Required tooling inside every node image), andnpm run test:integrationpasses. - Frontend:
<X>Node.tsx(viaBaseNode),nodeTypes,NodeLibrary,ContainerDataunion, drop placeholder. - Inspector modal + backend explorer wired (or intentionally omitted).
- README "Supported Infrastructure Nodes" list updated.
- Both frontend and backend build; tests pass.
- Manually verified the full lifecycle checklist above.
- Conventional Commit message.
Thank you for making Torollo better. 🎛️