Sitemap
Contained

Contained is a publication about containment: the walls that keep one workload, user or failure from reaching everything else. Each piece builds something real, then tests where those walls hold, where they leak and what a breach can reach without them.

How I Built a $20 Honeypot With a Kernel per Decoy.

13 min readOct 30, 2026

--

Disclosure: I work at Edera, who build hardened runtimes for your container and AI environments, without the complexity. This was a personal weekend project on Edera’s free tier.

A honeypot is the one system you build hoping someone breaks into it. That flips the usual question that security teams care about from the attack surface, into the blast radius. I wasn’t worrying at all about how to keep attackers out initally. I was asking what they could reach once they got in, and I wanted the answer to be nothing that matters.

In short: I am currently runing five honeypot decoys and two logging zones on one AWS Free plan EC2 instance. EderaON, the free single-node licence for Edera for Containers, runs each decoy in its own zone: a lightweight VM with its own Linux kernel. A compromised decoy would need a hypervisor escape to reach the host or the other decoys.

Most honeypot setups make one of two trade-offs. Projects like T-Pot run many decoys as containers on one shared kernel. That’s cheap, but a single kernel bug sits between an attacker and everything else on the box. Not something I would ever want to do in a real world environment connected to the internet. A full VM per decoy closes that gap, but then every decoy is a machine to build, patch and pay for.

I’ve just joined Edera, whose whole idea is to run each container in its own lightweight VM or zone as we call it with as little overhead as possible. I also didn’t want to particularly spend a lot on it. Like a lot of CISO’s, I have a range of projects that I want to explore building and tinkering with. So to start I wanted to see how far one free-tier EC2 instance could take me. Over a few weekends it has grown into seven isolated VMs, with runtime detection, central logging, tripwire credentials and email alerts. Pretty cool if you ask me.

AWS sign-up credits paid for the server. The only cash I spent was $20 on a month of Claude Pro, so I could use Claude Code, which wrote or debugged most of the scripts, so I didn’t cut into time on the weekends with my family.

What does a successful attacker do next?

Before I picked any software, I listed what someone who lands on a decoy would try next (a simplistic threat modeling exercise) — if they discovered it was a honeypot.

  1. Escape to the host. Containers share the host’s kernel, so one kernel bug or careless mount would put them next to my logs, my licence keys, any credentials or secrets stored and the instance’s AWS role.
  2. Tamper with the evidence I was collecting, or hop to the other decoys and the logging stack.
  3. Use my IP address to attack other people. Then I’m the source, and it’s my AWS account that gets suspended.

That list gave me one design rule: every layer assumes the one before it has already failed.

  • I assume the decoy software will be compromised, so each decoy gets its own kernel.
  • I assume that kernel might be escaped, so the host firewall treats every zone as hostile.
  • I assume the host might be reached, so it has no admin port and no zone can fetch its cloud credentials.
  • If all of that fails, a record of the connections should still exist outside the box, so I can shutdown the account.

Why use EderaON for a honeypot?

Obviously it was made a lot easier by having access to a free license to experiment with — but I naturally also looked at other ways to isolate the decoys.

The obvious one was hardened Docker containers. Seccomp, AppArmor, dropped capabilities and user namespaces all shrink the attack surface, but every decoy still shares one kernel. A kernel exploit can become a whole-host compromise, and anyone who realises they’re in a honeypot has good reason to try one.

The safe one was a full VM per decoy. The isolation is real, but every decoy becomes its own operating system to build and patch. The honeypot projects I wanted ship as container images, not VM images, so I’d have spent my weekends repackaging them, and then deal with the other hassle.

The third was Edera. It runs each ordinary container image in its own lightweight VM on a Xen-based hypervisor. Edera calls these VMs zones, and each one boots its own kernel.

Press enter or click to view image in full size

I did consider the other VM-per-container options. Kata Containers and Firecracker both need KVM, which this non-metal instance doesn’t expose. gVisor would run here, but it gives you a user-space kernel rather than a VM boundary, and I didn’t bother testing it. The Edera team has done the hardwork on comparisons already. On this instance, Edera was the only option that gave me both things I wanted. Cowrie, OpenCanary and Beelzebub run from their published images, and only one needed any work. And an attacker who breaks one of them lands in a kernel that serves only that decoy, with the host firewall still standing behind it.

The one that needed work was OpenCanary. Edera converts each image to a squashfs filesystem before running it, and OpenCanary’s published image failed with “squashfs packer failed”. It held 2,880 hardlinks, almost all from a Python package cache. Deleting the cache removed nearly all of them; I turned the last six into regular files and imported the image with protect image import.

EderaON makes it free for you to try as well. I signed up with a business email, created a licence key and installed Edera on a VM.

For this particular use case, I didn’t bother using Kubernetes — which is simple to do with Edera. On a single VM, I launched each zone directly with Edera’s protect CLI. In a cluster, Edera does the same job through a RuntimeClass and puts each pod in its own zone, so the pattern carries over to production.

Edera’s preinstall check passed every required test and failed two optional ones, IOMMU and PVH. That’s expected, and it’s why Edera works on ordinary cloud VMs. EC2 instances that aren’t bare metal don’t expose hardware virtualization (the CPU features VMs normally rely on) by default, so Edera runs the zones in Xen’s paravirtualized (PV) mode, which doesn’t need it. If you see those two failures, carry on. Each zone still gets its own kernel behind the hypervisor. PV does give up some things, such as GPU support, and it exposes more hypervisor surface than PVH, so Xen security advisories are on my watch list. But it meant I didn’t need a metal instance to put a VM boundary around each decoy.

How is the honeypot built?

One EC2 instance runs Edera, and Edera runs seven zones: five decoys and two for logging. Everything that guards or watches the decoys runs on the host, outside every zone. That’s the firewall, runtime detection and log shipping.

Press enter or click to view image in full size

I picked decoys that look like the services attackers scan for most, plus one newer target. Cowrie plays an SSH server on port 22, with planted files to keep an intruder busy. OpenCanary covers FTP and Redis. Beelzebub v3.9.2 fakes the rest: a PHP 8.4 site with MCP (Model Context Protocol, how AI agents call tools) paths on port 80, a MySQL 8.0.36 handshake on 3306, and an MCP server with two fake tools on 8080.

The MCP decoys are there because I really wanted to catch AI agents as well as bots, and an exposed AI-tool endpoint is just the kind of thing an agent might poke at. Beelzebub serves static responses here, since its LLM-backed mode would need outbound access.

The walls start at AWS. Until go-live, the security group let nothing in. Now it allows only the six decoy ports in, and only 80 and 443 out, for the host’s own needs: SSM, the alert relay, package updates from the Ubuntu, Grafana and Falco repositories and the snap store, and image pulls when I update a workload. Ubuntu’s background jobs (motd-news, firmware and update checks) use it too, which is easy to forget when you list what needs to get out. The zones themselves have no egress at all.

Get Claude Mandy’s stories in your inbox

Join Medium for free to get updates from this writer.

On the host, two nftables tables of my own do the real work:

  • DNAT on the public interface only: 22 to the SSH zone’s port 2222, 21 and 6379 to the canary zone, 80 to the PHP zone, 3306 to MySQL and 8080 to MCP.
  • An egress chain for traffic from any zone interface. It drops and logs anything sent to the metadata address 169.254.169.254, accepts replies to inbound sessions, allows exactly one zone-to-zone path (Grafana’s zone querying Loki’s on port 3100), and logs and drops everything else.
  • An input chain that lets a zone reach the host only with replies to connections the host opened.

Edera adds rules of its own, including one that accepts traffic on every zone interface. Mine still win, because in nftables a drop in any chain is final: an accept somewhere else can’t undo it. That matters because the firewall on this host isn’t only mine: Edera and Docker both add rules, and I needed to know my drops would still hold alongside theirs.

My own first draft of the setup added a blanket masquerade rule, which rewrites the source address of every connection to the host’s own. Inbound attacks would have arrived looking as if they came from the honeypot itself, and every log would have shown the same address instead of the attacker’s. Edera already rewrites traffic leaving zones, which my firewall blocks anyway, so I dropped the rule. A packet capture of a live scanner reaching the Cowrie zone confirmed that real attacker addresses arrive.

I locked down the host itself too. The instance requires IMDSv2 with a hop limit of 1 and has the IPv6 metadata endpoint switched off, so traffic forwarded from a zone can’t fetch the instance’s credentials. The egress chain also drops anything a zone sends to the metadata address, so that’s two locks on the same door. There’s no admin port; I get in through SSM Session Manager. The Edera licence key lives in Parameter Store as a SecureString, so it never sits in a terminal or unencrypted on disk. The host’s own sshd is off, so port 22 belongs to the decoy and nothing on the host listens for admins.

Then there’s the evidence. Someone who gets root inside a decoy may well try to clean up after themselves. So each decoy’s main log file is marked append-only on the host with chattr +a: a decoy can add lines but can't rewrite or delete them. I tested that from inside every zone: through Edera's mounts, append works and truncate or delete is refused. There's a trade-off. An append-only file can't be rotated, so I turned rotation off and let the logs grow; after 11 days the disk still has 24 GB free. Two things aren't protected this way: Cowrie's session recordings, and a raw wire log of MCP traffic, which does rotate. Grafana Alloy on the host tails those files into the Loki zone, so shipping logs never needs a network path out of a decoy. VPC Flow Logs also send connection records to S3, a record that doesn't depend on the host at all.

Last comes detection. Falco, an open-source runtime threat detector, runs on the host with Edera’s Falco plugin, which streams syscall events out of each zone. Without the plugin, host-side Falco would be blind to anything happening inside a zone. p0f and Cowrie’s HASSH fingerprint the operating systems and SSH clients that connect. I’d planned to add GuardDuty, but it wasn’t available in this account, so Flow Logs, the firewall’s drop logs and Falco are the tripwires.

Alerting turned into a small puzzle. Grafana’s zone has no egress, so it can’t send email. My fix was a small host script that checks Grafana for firing alerts and triggers a dedicated Canarytoken. The free Canarytokens tripwire service then sends me the email. More Canarytokens sit inside the decoys as fake credentials, and they email me the moment anyone uses them.

How did seven VMs fit on two vCPUs?

The server I landed on is an m7i-flex.large: 2 vCPUs, 8 GB of memory, Ubuntu 24.04. I’d planned an m5.large, but the Free plan only launches free-tier-eligible types and refused it. The m7i-flex.large has the same shape and boots UEFI, which Edera accepts.

CPU was the first limit I hit. My first zone launch failed with zone max_cpus (4) exceeds host pCPU count (2). Edera gives each zone four vCPUs by default, more than the whole host has, so every launch now passes --min-cpus 1 --max-cpus 2. Honeypots sit idle most of the time, so sharing two cores costs little.

Memory is the real limit. Dom0 (Xen’s management VM, here the Ubuntu host) takes about 3.7 GB of the 8 GB, which leaves about 4.1 GB for zones. Five zones run in a 512 MB to 1 GB range (SSH, canary, MCP, Grafana and Loki), MySQL is fixed at 512 MB and the PHP decoy at 256 MB. Together the seven hold all of it: the hypervisor reports 0 MB free, and the SSH zone runs about 200 MB below its 1 GB target. Seven is the ceiling at these settings; trimming dom0 and the smaller decoys would make room for more.

Did the walls hold?

Yes. In the first eight and a half days, Falco logged no critical event caused by outside traffic in any zone. The firewall dropped 84,239 connections that zones tried to start, and none got out.

Falco can see inside the zones. Its only two zone alerts so far came from my own test, reading /etc/shadow and running nsenter. It hasn't fired on an attacker because no attacker has run a real command: everyone who got in landed in an emulated shell.

That number surprised me until I looked at it. Nearly all of them were DNS lookups by Loki and Grafana for usage reporting. After I switched that off on 23 October, the count fell to a few hundred over five days, most of them my own isolation tests. If you rebuild this, turn off usage reporting before you go live, or your drop log will be mostly noise.

Edera’s open-source am-i-isolated tells the same story from the inside. In every zone it reports an Edera runtime, the zone's own kernel and no container-runtime sockets. The same tool run in plain Docker on the same host shows the shared host kernel.

What doesn’t isolation cover?

A VM per decoy obviously narrows the risk. It doesn’t eliminate risk — even though that would make it a much easier product to sell.

  • The hypervisor is now the boundary. A flaw in it would put an attacker on the host, which is why I keep the host nearly empty: an SSM role and read access to one Parameter Store entry. This means patching Xen promptly.
  • Isolation costs some realism. A careful attacker can spot Xen inside the guest, recognise default decoy banners, or notice that nothing gets out. Change the default banners before you go live.

After the hypervisor, the host is the next target, so it’s built to give an attacker as little as possible. Two programs on it, p0f and the MCP wire logger, read raw packets that attackers control. Neither ever needs to open a connection; they only listen. So systemd’s IPAddressDeny=any blocks both from making any network connection. If a malformed packet ever got code running in either, it couldn't download a second stage or call home through the security group's open 80 and 443.

Three smaller choices follow the same rule of giving nothing away:

  • Alloy’s usage reporting is off, so apart from SSM the host keeps no outbound connection open, and it doesn’t tell anyone which software versions it runs.
  • Falco’s web server isn’t on the network, so its safety doesn’t depend on other layers happening to block it.
  • The live firewall rules are part of the integrity check that watches the decoy files, so any change to them emails me within minutes. The firewall is what keeps the zones contained, so it gets the same tamper alerting as everything else.

If anything goes wrong, the kill switch is one change: set the security group back to deny all inbound.

Advice for a rebuild

Before you go live, check your cloud provider’s acceptable use policy and your employer’s view. You’re inviting attacks on infrastructure you’re responsible for. Decide, too, how long you’ll keep what you capture and who can read it: the logs hold attackers’ IP addresses and whatever they type, which can include other people’s stolen credentials.

Build and test the walls before any decoy goes live. A leaky honeypot is worse than none. I gave every wall a test I’d seen fail first: from inside a zone, try the internet, the host and the metadata service, and confirm each attempt is dropped. That habit caught my worst bug. The first version of my firewall never loaded, because I’d named the chain fwd, a keyword in nft 1.0.9, so nft rejected the whole file and every probe got through, the metadata service included. My first egress test (wget example.com || echo blocked) couldn't have caught it. It would have printed "blocked" anyway, because the security group was already blocking DNS. I'd written a test that couldn't fail. My egress test now probes IP addresses directly from a throwaway Alpine workload in every zone, after first running against a known-open target to prove it can tell open from closed. The full story is in the repo's docs/BUILD.md.

Use a coding agent, and read what it writes. Claude Code wrote or debugged most of my install scripts, firewall rules and fixes, which is how this fitted into weekends during my first weeks at a new job. I still checked every firewall rule before it went live.

What it caught, and what happened when I sent my own AI agent into it, is the next piece. After that, I’ll take the same design to a Kubernetes cluster, with a kernel per decoy across several regions.

--

--

Claude Mandy

Written by Claude Mandy

Aussie from the Namib desert, now in the Bay Area. Field CTO at Edera.dev, securing containers & AI agents. Ex-CISO, ex-Gartner. Not an AI (see: dad jokes)

Contained

Published in Contained

Contained is a publication about containment: the walls that keep one workload, user or failure from reaching everything else. Each piece builds something real, then tests where those walls hold, where they leak and what a breach can reach without them.