
Medium-interaction SSH/Telnet honeypot built with Cowrie, Loki, Promtail, and Grafana - provisioned on DigitalOcean via Terraform with a GitLab CI validation pipeline.
An SSH/Telnet honeypot stack deployed on a DigitalOcean droplet using Terraform. Attacks are captured by Cowrie, stored in Loki, and visualised in Grafana with a live world map of attack origins.
honeypot/
├── cowrie/
│ └── etc/
│ ├── cowrie.cfg # Cowrie honeypot configuration
│ └── userdb.txt # Accepted and rejected fake credentials
├── geoip/
│ └── .gitkeep # Placeholder - MMDB files are gitignored
├── grafana/
│ ├── provisioning/
│ │ ├── dashboards/
│ │ │ ├── dashboards.yml # Provisioning: dashboard provider
│ │ │ └── honeypot-dashboard.json # Pre-built Attack Monitor dashboard
│ │ └── datasources/
│ │ └── loki.yml # Auto-provisioned Loki datasource
│ └── grafana.ini # Grafana server settings
├── loki/
│ └── config.yml # Loki single-binary config and retention
├── promtail/
│ └── config.yml # Promtail scrape and GeoIP pipeline
├── scripts/
│ ├── geoip-update.sh # Download or refresh GeoIP database
│ └── setup-firewall.sh # Host UFW rules for honeypot ports
├── terraform/
│ ├── templates/
│ │ ├── cloud-init.yaml.tftpl # Droplet first-boot script (Terraform-templated)
│ │ └── env.tftpl # `.env` lines embedded via Terraform
│ ├── backend.tf # Terraform backend config
│ ├── main.tf # Droplet, SSH key, firewall, cloud-init
│ ├── outputs.tf # IPs and helpful post-apply values
│ ├── terraform.tfvars.example # Example variable values (copy to terraform.tfvars)
│ ├── variables.tf # Terraform input variables
│ └── versions.tf # Terraform and provider version constraints
├── .env.example # Example environment file for manual setup
├── .gitignore # Ignored paths and files
├── .gitlab-ci.yml # CI/CD pipeline: fmt, validate, Checkov
├── docker-compose.yml # Docker Compose file for the honeypot stack
├── LICENSE # GPLv2 license
├── manual-deployment.sh # Legacy VM bootstrap without Terraform
└── README.md # Project documentation
Note: These requirements are based on the DigitalOcean Basic Droplet plan as of May 2026 and are the minimum requirements to run the project.
Note: After installation, it is recommended to open a new terminal and verify you can still SSH on
ADMIN_SSH_PORT(Default: 2022) before closing your original session.
Create an SSH key pair on your local machine and copy the public key path.
Go to the terraform directory then copy and edit the variables file:
Note:
do_token,ssh_public_key_path, andgrafana_admin_passwordmust be configured at minimum interraform.tfvarsbefore applying.
cd terraform
cp terraform.tfvars.example terraform.tfvars
Note: Terraform must be installed on your local machine. Visit Terraform Installation Guide for instructions.
terraform init # Initialize Terraform and download providers
terraform apply
terraform apply -replace="digitalocean_droplet.honeypot"
terraform destroy
Create a DigitalOcean droplet with the above hardware requirements and your SSH key.
SSH into the droplet or use the DigitalOcean web console and run the following commands:
ssh root@<your-droplet-ip> # If using DigitalOcean web console, skip this command
git clone https://gitlab.com/Oseguera12/cowrie-honeypot-digitalocean.git /opt/honeypot
cd /opt/honeypot
cp .env.example .env
nano .env
bash manual-deployment.sh
Note: By default, Terraform writes state to
terraform/terraform.tfstateon your local machine. This file contains sensitive output values (droplet IP, Grafana password, SSH key fingerprint) and must never be committed..gitignorecovers*.tfstateand*.tfstate.*.
Risks of local state:
apply runs can corrupt the fileFor anything beyond a personal lab, switch to a remote backend. terraform/backend.tf contains a commented-out DigitalOcean Spaces configuration (S3-compatible).
To enable remote backend:
Create a Spaces bucket in your DigitalOcean account
Generate a Spaces access key under API > Spaces Keys
Export the keys as environment variables (do not put them in terraform.tfvars):
export AWS_ACCESS_KEY_ID=<spaces-access-key>
export AWS_SECRET_ACCESS_KEY=<spaces-secret-key>
Uncomment the backend "s3" block in terraform/backend.tf and fill in your bucket name and region endpoint.
Run terraform init -migrate-state to move existing local state to Spaces.
.gitlab-ci.yml runs three jobs on every push in a single validate stage:
Note:
terraform:fmtandterraform:validateblock the pipeline on failure.checkov:scanis set toallow_failure: truebecause some findings are intentional trade-offs. Suppress specific acceptable findings with inline# checkov:skip=CKXXXcomments rather than disabling the job entirely.
CKV_DIO_4 ("Ensure the firewall ingress is not wide open") fires once against the entire digitalocean_firewall resource. It is suppressed with a single # checkov:skip=CKV_DIO_4: comment placed inside the resource block in terraform/main.tf. The table below documents why each open port is intentional.
flowchart TB
internet((Internet))
subgraph tf["Terraform"]
fw[DigitalOcean Cloud Firewall]
droplet[Ubuntu 24.04 droplet]
fw --> droplet
end
subgraph compose["Docker Compose on droplet"]
cowrie["Cowrie honeypot — SSH on port 22, Telnet on port 23"]
promtail[Promtail with GeoIP labels]
loki[Loki log store]
grafana["Grafana — host :3000"]
cowrie -->|JSON logs from ./data/cowrie-logs| promtail --> loki --> grafana
end
internet -->|22/23 honeypot| fw
internet -->|Admin SSH :2022| fw
internet -->|Grafana :3000| fw
droplet --> compose
Data captured over 5 days of live deployment (2026-05-05 to 2026-05-09):
The most common username attempted was root with 3,866 attempts, followed by
admin (728) and user (494), reflecting automated scanners targeting
default credentials and known service accounts. The most common password was
123456 (1,480 attempts) followed by 123 and 12345, consistent with
dictionary-based brute-force tooling.
868 login attempts succeeded against the fake credential set. Of those sessions,
836 commands were executed. The most common command was uname -s -v -n -r -m
(360 executions) — a standard system fingerprinting command run by automated
post-exploitation scripts to identify the target OS and architecture before
deploying a payload. Other observed commands included export HISTFILE=/dev/null
to disable shell history logging, and export HISTSAVE=/dev/null, indicating
attackers actively attempting to cover their tracks even inside what they believed
was a compromised system.
The volume of automated scanning traffic — 41,700 connections in 5 days from 106
countries — confirms that any publicly accessible SSH service faces constant
brute-force attempts within hours of exposure. The behavioral pattern of
immediately running uname followed by history-suppression commands is consistent
with automated post-exploitation frameworks operating with minimal human
involvement.
Note: Dashboard Credentials are set in terraform.tfvars or .env (for manual deployment)
Dashboard can be accessed at:
http://<your-droplet-ip>:3000
Login with admin / <GRAFANA_ADMIN_PASSWORD>.
cowrie/etc/userdb.txt - contains the accepted credentials for the honeypot.
Edit this file to tune the honeypot credentials
Accepted logins drop the attacker into a fake shell where all commands are logged
Rejected entries are logged as failed attempts
This section details the decisions made and issues encountered during the development of this project. It serves as a reflection of the project's development process and lessons learned. Skip to Security Considerations if you only want to know how to use the project.
Why Cloud Hosting?
Why DigitalOcean?
Why Terraform?
manual-deployment.sh, is for users who are not experienced with Terraform or prefer not to use it. That path requires provisioning a droplet, connecting to it, cloning the repo, and running the script. Terraform allows quicker iteration: edit the codebase and redeploy without repeating each manual step from your local machine. It also makes it easy to tear down infrastructure and start fresh. If the project grows from a single honeypot into a honeynet, Terraform scales more cleanly than manual provisioning or the legacy script alone.Why Cowrie?
Why Loki?
Why Grafana?
Why Promtail?
Why GeoIP?
scripts/geoip-update.sh: no account or API key, which keeps deployment simple for anyone cloning the repo. Promtail's built-in GeoIP stage reads the local MMDB file and adds labels for the Grafana Geomap panel.Step by step overview of what occurs when the honeypot is setup using Terraform or the manual deployment script.
On your machine:
terraform/terraform.tfvars: API token, SSH public key path, Grafana password, repo URL/branch, optional manage_do_firewallterraform init: installs the DigitalOcean provider locallyterraform apply: Terraform builds a plan, then creates or updates resourcesIn DigitalOcean:
droplet_image, droplet_size, region, etc.user_data is set to the rendered cloud-init: on first boot the VM gets the admin SSH port, git repo_url / repo_branch, and a base64-encoded copy of the generated .env (Grafana password and ADMIN_SSH_PORT from env.tftpl)manage_do_firewall is true):
terraform/main.tf)On the droplet, cloud-init runs automatically:
package_update / package_upgrade, then install listed packages (curl, git, ufw, …). Early in boot, write_files creates /root/honeypot.env (Terraform-rendered env.tftpl as base64: Grafana password, ADMIN_SSH_PORT, etc.)runcmd bootstrap script (order in terraform/templates/cloud-init.yaml.tftpl): set iptables / ip6tables to legacy backends/swapfile, fstab, swappiness)Inside Docker Compose:
/ready on port 3100 in-container; on the host only 127.0.0.1:3100)depends_on in docker-compose.yml), then startdata/cowrie-logs for PromtailUser creates the VM, SSH in, and runs the script from the cloned repo.
manual-deployment.sh does the following:
.envapt update & apt upgradeufw and iptablessshd on ADMIN_SSH_PORT and disable ssh.socketscripts/setup-firewall.shscripts/geoip-update.sh and monthly crondocker compose pull / up -d, then the script waits for Loki and Grafana health checks and prints a summarySolutions Considered:
Resolution:
Trade-offs:
Lessons Learned:
docker compose up failed because port 22 was already in use and could not be bound by Cowrie.Solutions Considered:
sshd was misconfigured or listening on the wrong portResolution:
sshd is moved to ADMIN_SSH_PORT (default 2022) and restarted first, then ssh.socket is stopped and disabled to free port 22 for Docker to bind for Cowrie. UFW rules are applied after both steps to allow the new admin port.Trade-offs:
Lessons Learned:
ssh.socket is a systemd socket unit that keeps port 22 reserved for on-demand SSH activation. It must be stopped and disabled before Docker can bind host port 22 for Cowrie. If it's still active, docker compose up fails with "address already in use".GeoIP Functionality
Grafana HTTPS
Proxy Server Functionality
GNU General Public License Version 2.0 (GPLv2) See the full license text in the LICENSE file.
| Job | Tool | Purpose |
|---|
terraform:fmt | hashicorp/terraform:1.8 | Formatting: fails if any file needs terraform fmt |
terraform:validate | hashicorp/terraform:1.8 | Config validity without contacting DigitalOcean |
checkov:scan | bridgecrew/checkov:latest | IaC misconfigurations in the Terraform code |
| Check ID | Resource | Finding | Decision |
|---|
CKV_DIO_4 | digitalocean_firewall.honeypot — admin SSH inbound | Admin SSH port open to 0.0.0.0/0 | Accepted — restricting to a fixed IP is impractical for a portable lab; recommended in production |
CKV_DIO_4 | digitalocean_firewall.honeypot — port 22 inbound | Port 22 open to 0.0.0.0/0 | Intentional — this is the SSH honeypot surface; restricting the source defeats the purpose |
CKV_DIO_4 | digitalocean_firewall.honeypot — port 23 inbound | Port 23 open to 0.0.0.0/0 | Intentional — Telnet honeypot surface; same reasoning as port 22 |
CKV_DIO_4 | digitalocean_firewall.honeypot — port 3000 inbound | Grafana HTTP exposed to 0.0.0.0/0 | Accepted for lab accessibility — known limitation documented in Security Considerations; production deployments should restrict to a known IP or proxy through HTTPS on port 443 |
| Metric | Value |
|---|
| Total connections | 41,700 |
| Login attempts | 15,400 |
| Successful logins | 868 |
| Commands executed | 836 |
| Files downloaded | 6 |
| Unique source countries | 106 |
| Country |
|---|
| Connections |
|---|
| Germany | 9,768 |
| Netherlands | 9,110 |
| United States | 7,706 |
| United Kingdom | 3,858 |
| Singapore | 1,770 |
| Belgium | 1,422 |
| Panel | Description |
|---|
| Attack World Map | Geomap with heatmap + marker layers showing origin of every connection |
| Total Connections | Count of inbound honeypot sessions in the selected time range |
| Login Attempts | Total credential brute-force attempts |
| Successful Logins | Attackers who matched userdb.txt credentials |
| Commands Executed | Shell commands executed inside the fake shell |
| Files Downloaded | Malware/scripts captured via wget/curl |
| Connection Rate | Time series: connections/s, failed logins/s, successes/s |
| Top Usernames | Most-tried SSH usernames |
| Top Passwords | Most-tried passwords |
| Top Commands | Most-executed shell commands |
| Attacks by Country | Country-level connection table |
| Recent Events | Live log stream of latest events |
| File Downloads | Table of every file an attacker tried to pull |
systemctl enable --now dockersshd on ADMIN_SSH_PORT; disable ssh.socket so host port 22 is free for Cowriegit clone from repo_url / repo_branch into /opt/honeypot (must include docker-compose.yml and configs).env on disk: move /root/honeypot.env to /opt/honeypot/.env (mode 600)scripts/setup-firewall.sh (UFW for admin SSH, 22, 23, 3000)scripts/geoip-update.sh (DB-IP City Lite into geoip/); append a monthly cron line for the same scriptdata/cowrie-logs and data/cowrie-dl with permissions Compose expects/opt/honeypot, docker compose pull then docker compose up -d