A default honeypot is nobody’s machine. It answers ls with a home directory that belongs to no one, prints a hostname nobody picked, and serves a login page for a company that was never incorporated. Whoever lands on it spends the first few seconds asking one question: is this real? A box with no story answers it for them.
The stock reality
Install Beelzebub, point it at the example configs, and five minutes later you have a working SSH honeypot. It takes a few common passwords and answers a handful of commands from a canned list. Here’s what ships:
apiVersion: "v1"
protocol: "ssh"
address: ":22"
description: "SSH interactive"
commands:
- regex: "^ls$"
handler: "Documents Images Desktop Downloads .m2 .kube .ssh .docker"
- regex: "^pwd$"
handler: "/home/"
- regex: "^uname -m$"
handler: "x86_64"
- regex: "^(.+)$"
handler: "command not found"
serverVersion: "OpenSSH"
serverName: "ubuntu"It works. Now read it as the person who just logged in. pwd returns /home/ with no username on it. The prompt calls the host ubuntu. ls shows a .kube and a .docker but no code, no project, nothing that says why this machine is on the internet. It’s a film-set storefront: fine from the sidewalk, hollow the second someone opens the door. And every operator who kept the defaults is running this same set.
What it’s missing is a persona, and anyone who pokes at boxes for a living clocks the absence right away.
The lever
Give the box a specific reason to exist, and make every answer agree with it.
Not “make it look like a server.” Beelzebub already does that. Make it look like one server, a particular one: some two-person startup’s staging box, a hobby project’s API host, a build machine for a small team. Specific enough that the details lock to each other.
Do it
Before you touch a config, write the box down. Four questions, answered concretely:
- Who owns it, and what do they do? A two-person analytics startup. The box is
stg-01, the staging server, and the dev who lives on it ismara. - Why is it on the internet at all? It runs a small FastAPI app that takes events from a partner’s webhook, so it has a web port open. That port is the reason the box exists, and the reason it’s worth a look.
- What’s the one thing worth finding on it? Staging shares a cloud account with production, so there are real-shaped credentials in an environment file. (We put that to work in the next Groundwork.)
- What’s not here? No customer database, no
/homepacked with other users, no corporate SSO. A two-person shop’s staging box is small and a little messy. Grandeur is a tell too. A lone dev’s server that answers like a Fortune 500 fleet is wrong in the other direction.
That’s the spec. From here, every config line just implements one of those answers. A line that doesn’t map to one is a loose end waiting to trip you.
Now make the box say it. Three edits.
1. Fix the identity fields. serverName sets the shell hostname; make it the machine in your story, not ubuntu. And serverVersion: "OpenSSH" on its own is a giveaway, since no real sshd banner looks like that. Copy one off a box you actually run:
serverVersion: "OpenSSH_8.9p1 Ubuntu-3ubuntu0.6"
serverName: "stg-01"2. Make the filesystem cohere. Every handler is a chance to back the story up or blow it. Rewrite them so someone running ls, pwd, and whoami gets three answers describing the same machine:
commands:
- regex: "^whoami$"
handler: "mara"
- regex: "^pwd$"
handler: "/home/mara"
- regex: "^hostname$"
handler: "stg-01"
- regex: "^ls$"
handler: "deploy.sh ingest requirements.txt venv"
- regex: "^ls ingest$"
handler: "__init__.py fetch.py normalize.py config.yaml"
- regex: "^cat requirements.txt$"
handler: "fastapi==0.111.0\nhttpx==0.27.0\npsycopg2-binary==2.9.9"
- regex: "^cat /etc/hostname$"
handler: "stg-01"
- regex: "^cat deploy.sh$"
handler: "#!/usr/bin/env bash\nset -e\ncd /home/mara\nsource venv/bin/activate\nexec uvicorn ingest.app:app --host 0.0.0.0 --port 8000"
- regex: "^(.+)$"
handler: "command not found"whoami is mara, pwd is /home/mara, requirements.txt names FastAPI, and nothing fights. Watch how the details start pointing at each other: deploy.sh launches uvicorn, which is the server the web port will report; it runs the ingest module that ls ingest already showed; /etc/hostname matches the prompt. That cross-referencing is what a real machine does for free. Pull any one thread and the rest are already tied to it. That web is what you’re building. Stacking up more individually-plausible answers doesn’t get you there; the first person who cross-checks two of them finds the seam.
3. Make the front door match. If you run the HTTP service too, the web response has to belong to the same company. The stock config serves a generic “Hello from Wordpress” page; swap it for something that names the same box:
commands:
- regex: "^(/|/health)$"
handler: '{"service":"ingest","status":"ok","host":"stg-01"}'
headers:
- "Content-Type: application/json"
- "Server: uvicorn"
statusCode: 200Now the web port and the shell agree: stg-01, an ingest service, uvicorn out front.
Running the LLM-backed honeypot instead of static handlers? Same idea, one field. The LLMHoneypot plugin takes an optional prompt, and that prompt is your whole persona:
plugin:
llmProvider: "ollama"
llmModel: "llama3.1:8b"
host: "http://localhost:11434/api/chat"
prompt: >
You are a Linux shell on stg-01, the staging server of a
two-person analytics startup. The primary user is mara. The
box runs a FastAPI ingest service under /home/mara. Answer as
that shell would. Never break character or mention being an AI.Static handlers or LLM, the discipline is identical: settle the story once, then hold every surface to it.
The way this goes wrong is half-finished. You rename the host to stg-01 but cat /etc/hostname still says ubuntu. You build a Python box but leave the stock ls listing .m2 and .kube, ghosts of a Java shop that isn’t in your story. You write a careful FastAPI backstory and the web port answers in WordPress. Nobody has to spot the whole contradiction. One line that doesn’t fit turns “real” into “why is this here,” and once they’re asking that, everything else you built is talking to someone who already left. There’s no partial credit for a mostly-consistent box.
The thinking
This is the part that transfers, so it’s worth slowing down on. Someone landing on a strange box does what you’d do: builds a picture of the machine and starts checking it. whoami, pwd, ls, cat /etc/os-release, maybe history. Every answer is a test. A real server passes for free, because it is what it claims to be. A honeypot only passes on purpose, and it blows the instant two answers disagree: a home directory that doesn’t match the user, a hostname that shifts between the prompt and hostname, a Python box wearing a WordPress banner.
So stop thinking about individual responses and start thinking about whether they contradict each other. Chasing realism sends you down a rabbit hole of emulator fidelity that mostly doesn’t matter. The work that pays is narrower: make sure nothing the box says disagrees with anything else it says. You don’t need a perfect Linux; you need a box that never trips over its own story.
That’s the question to carry into any setup, whether it’s Cowrie, T-Pot, or a bare HTTP trap. Not “how do I make this look real,” but “what is this machine, exactly, and does everything I can think to check line up?” Write the story first. The rest is just typing it in.
What changed for us
At scale, the first thing that jumps out is how few visitors ever run a command. Most connect, maybe grab the banner, and leave. That’s automated scanner background, and no amount of persona work moves it; those sessions never even type whoami, so nothing you wrote past the banner was ever for them.
The persona earns its keep on the ones who stay. Running a command means someone decided, in a second or two, that the box was worth a question, and you can watch the ones who change their mind. A session that runs whoami, gets an answer that doesn’t fit, and drops without a follow-up looks nothing like one that keeps digging. You’ll see both in your own logs the day after you tighten the story.
I’ll be straight about the number we can’t hand you: how much a good persona is actually worth, cleanly measured. Getting that right takes a controlled rig and more traffic than a weekend gives you. The version you can run is cruder and still tells you plenty. Do they stay, do they dig, do they come back? A box that holds together gives you more of all three to read.
Try this next
A believable box gets people exploring, but exploring is thin evidence: all you learn is that somebody looked. The stronger tell is when they take something. Next Groundwork, we plant a credential worth stealing and watch where it turns up. Getting them through the door was the easy part.