---
title: "Building Tirraflow Part 5: Hardening the VPS After a Cryptominer Got In"
url: "https://tirraflow.com/posts/hardening-a-self-hosted-vps-after-a-cryptominer-got-in"
author: "taoufiq"
published: "2026-06-13T13:23:32Z"
updated: "2026-09-25T21:01:46Z"
category: "Security"
difficulty: "advanced"
series: "Building Tirraflow"
series_part: 5
series_url: "https://tirraflow.com/series/building-tirraflow"
tags: ["Docker", "Keycloak", "CI/CD", "Security"]
---

# Building Tirraflow Part 5: Hardening the VPS After a Cryptominer Got In

One day my little VPS — the one running [Tirraflow](https://tirraflow.com) — was suddenly burning CPU it had no reason to burn. A cryptominer had found its way in. I cleaned it up, but cleanup is the easy part. The real question is: **_how did it get in, and how do I make sure it can't happen again_**_?_

This post is the answer. No fancy tooling — just a methodical, layered hardening of a single Docker-Compose server. Everything here is defensive and reproducible on any small VPS.

---

## How It Got In

The post-mortem was humbling, because the entry path was boring. Two ordinary misconfigurations lined up into a straight road to root:

1. **SSH password authentication was enabled.** Bots brute-force port 22 around the clock. A username plus a guessable password is all they need.
2. **The deploy user had a password _and_ was in the** `docker` **group.** Membership in the `docker` group is effectively root — the Docker socket can mount the host filesystem. So a single weak password didn't grant "a normal account," it granted the whole machine.

Chain those together and the attack is almost trivial:

```mermaid
flowchart TB
    bot["Internet bot"] -->|"brute-forces SSH<br/>(password auth ON)"| user["deploy user"]
    user -->|"password guessed"| shell["shell"]
    shell -->|"member of the “docker” group"| mount["docker run -v /:/host …"]
    mount --> root["root on the host"]
    root --> miner["cryptominer installed, CPU maxed"]

    classDef bad stroke:#dc2626,stroke-width:3px
    class root,miner bad
```

_The breach chain: a bot brute-forces SSH with password auth on, lands on the deploy user, which is in the docker group, and uses a bind mount of the host root to become root and install a cryptominer_

The lesson isn't "I picked a bad password." It's that **one weak control can erase every other control**. Defense has to be layered.

---

## Step 0: Confirm the Box Is Actually Clean

Before hardening, make sure you're not hardening _around_ a backdoor. A quick triage checklist for any Linux host:

| Check | Looking for |
| --- | --- |
| `ps aux --sort=-%cpu` | Unexpected CPU hogs / odd process names |
| `crontab -l`, `/etc/cron.*`, systemd timers | Persistence that re-installs the payload |
| `~/.ssh/authorized_keys` (every user) + `/root` | Attacker-planted keys |
| `ss -tlnp` | Services listening on public interfaces that shouldn't be |
| `/etc/ld.so.preload`, recent files in `/tmp`, `/dev/shm` | Rootkits / dropped binaries |
| SSH auth log | Which account logged in, from where, by which method |

That last one is gold. My auth log showed exactly one successful _password_ login — from a cloud IP that wasn't me — while every legitimate login used a key. That single line confirmed both the account used and the method, and pointed straight at the root cause.

---

## Layer 1: The Front Door — SSH

The most important fix. Make SSH key-only and stop accepting passwords entirely:

```text
# /etc/ssh/sshd_config.d/99-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
```

Validate and reload — never blindly restart and lock yourself out:

```bash
sudo sshd -t && sudo systemctl reload sshd
```

Two more touches:

- `AllowUsers` — explicitly list who may log in. Anyone not on the list is rejected before authentication even starts. (Just remember to include your automation account, or your CI/CD will silently start failing.)
- `fail2ban` — bans IPs after repeated failures. With password auth off it's belt-and-suspenders, but it keeps the noise (and log spam) down.

With password auth disabled, the brute-force road simply ends. The bots keep knocking; the door no longer opens.

---

## Layer 2: Don't Expose What Doesn't Need Exposing

The database, the auth server, the admin panel — none of these should be reachable from the internet. In a Docker-Compose setup the trap is publishing ports you only meant for internal use:

```yaml
# DON'T — binds to 0.0.0.0, reachable from the whole internet
ports:
  - "5432:5432"

# DO — bind to localhost only (reach it via an SSH tunnel when needed)
ports:
  - "127.0.0.1:5432:5432"

# BEST — don't publish at all; talk over the internal Docker network
# (no "ports:" entry; only the reverse proxy is public)
```

The shape you want: only the reverse proxy is exposed (80/443), everything else lives on an internal network.

```mermaid
flowchart TB
    net["Internet"] -->|"80 / 443 only"| nginx["Nginx<br/>the only public service"]

    subgraph internal["internal docker network"]
        direction LR
        f["frontend"]
        a["API"]
        k["Keycloak"]
        p["Postgres<br/>(no public port)"]
    end

    nginx --> f & a & k
    a --> p

    classDef edge stroke:#2563eb,stroke-width:3px
    class nginx edge
```

_After hardening, only nginx is reachable from the internet on ports 80 and 443; the frontend, API, Keycloak and Postgres sit behind it on an internal Docker network with no public port_

**A trap worth calling out:** on many distros Docker writes its own iptables rules that bypass `firewalld`/`ufw` for _published_ ports. So a port can be open to the world even though your firewall "blocks" it. After every deploy, verify reality from the host:

```bash
ss -tlnp   # expect only 22, 80, 443 on 0.0.0.0
```

---

## Layer 3: Contain the Blast Radius

Assume a service _will_ eventually be exploited. The goal is to make that foothold as boring as possible. Two cheap container settings do a lot:

```yaml
services:
  api:
    security_opt:
      - no-new-privileges:true   # block setuid privilege escalation
    deploy:
      resources:
        limits:
          cpus: "1.0"            # a miner can't eat the whole box
          memory: 512M
```

- `no-new-privileges` stops a process from gaining more privileges via setuid binaries — a common local-escalation trick.
- **CPU/memory limits** won't stop a breach, but they cap the damage. A cryptominer that can only have one core is a far smaller problem (and far easier to notice) than one that pins all of them.

If the container doesn't need to write to its own filesystem, add `read_only: true` with a small `tmpfs` for scratch space.

---

## Layer 4: Hardening at the Application Edge

A few HTTP- and app-level controls close off the easy abuse paths:

| Control | Why it matters |
| --- | --- |
| **HSTS** header | Forces HTTPS for every future visit, blocking downgrade/strip attacks |
| **Content-Security-Policy** | Limits where scripts/connections can go — contains XSS |
| **Rate limiting at the proxy** | Drops floods before they reach the app (cheaper there than in code) |
| **Trusted-proxy config** | So the app trusts the real client IP only from the proxy, not a spoofed header |
| **Upload validation** | Type + size _and_ dimension checks — a tiny "pixel-flood" image can decode to gigabytes and OOM the server |

The trusted-proxy one is subtle and worth a sentence. Many frameworks trust `X-Forwarded-For` by default. Behind a proxy that means a client can _spoof_ their IP — defeating per-IP rate limits and poisoning logs. Tell the app to trust that header only when it comes from your proxy's network.

And the image upload one bit me on review: validating content-type and a 10 MB cap isn't enough. Decode the header first and reject absurd dimensions _before_ processing:

```mermaid
flowchart LR
    up["upload"] --> ct["check content-type<br/>(jpeg/png/gif/webp)"]
    ct --> size["check file size<br/>(≤ 10 MB)"]
    size --> dim["check DECODED dimensions<br/>(≤ ~40 MP)"]
    dim --> store["resize / store"]

    classDef key stroke:#d97706,stroke-width:3px
    class dim key
```

_Upload validation order: content type, then file size, then the decoded pixel dimensions — the check people forget — before resizing and storing_

---

## Layer 5: Keep the Host Patched — and Reboot

Automatic updates are great, with one catch I almost missed: **they install new kernels but never reboot.** My server had a fully-patched kernel sitting on disk while still _running_ a months-old one — so the running system had known, already-fixed vulnerabilities live.

```bash
# Is a reboot needed to activate patched core libs / kernel?
needs-restarting -r
```

If it says a reboot is required, schedule one. With `restart: unless-stopped` on your containers, the whole stack comes back on its own in seconds. "Updates installed" and "updates running" are not the same thing.

---

## Bonus: Secure CI/CD Without Re-opening the Door

Automated deploys need credentials, and it's tempting to hand them broad access. Don't. The model that keeps things tight uses **two different, narrow credentials**:

```mermaid
flowchart TB
    subgraph inbound["Inbound — GitHub Actions → VPS"]
        direction LR
        gh["GitHub Actions"] --> vps["VPS"]
        n1["deploy SSH key · key-only · forwarding disabled<br/>dedicated deploy account in AllowUsers"]
    end

    subgraph outbound["Outbound — VPS → GitHub (pull code)"]
        direction LR
        vps2["VPS"] --> gh2["GitHub"]
        n2["READ-ONLY deploy key, one per repo<br/>a leaked key = read access to a single repo, nothing more"]
    end
```

_Two credential directions: GitHub Actions connects inbound to the VPS with a dedicated deploy account and key-only auth, while the VPS pulls code outbound using a read-only deploy key scoped to one repository_

The principle: every credential should do exactly one job and no more. A scoped, read-only key that leaks is an incident; an account-wide read/write key that leaks is a catastrophe.

One more hard-won habit: **never edit config directly on the server.** A manual tweak left the repo "dirty," which silently blocked `git pull` on every future deploy — so the pipeline kept "succeeding" while shipping stale config. Make the deploy script fail loudly (`set -euo pipefail`, `git pull --ff-only`) and treat the server's files as read-only artifacts of Git.

---

## Defense in Depth, On One Page

No single layer is the answer. Stacked, they turn "one weak password = full compromise" into "an attacker has to beat all of these":

```mermaid
flowchart TB
    l1["1 · SSH<br/>key-only, no root, AllowUsers, fail2ban"] --> r1["close the front door"]
    l2["2 · Network<br/>only 80/443 public; DB and auth internal-only"] --> r2["shrink the surface"]
    l3["3 · Containers<br/>no-new-privileges, resource limits"] --> r3["cap the blast radius"]
    l4["4 · App edge<br/>HSTS, CSP, rate limit, upload + proxy validation"] --> r4["block easy abuse"]
    l5["5 · Patch + reboot"] --> r5["no stale kernels"]
    l6["6 · CI/CD<br/>scoped, read-only, key-only credentials"] --> r6["least privilege"]

    l1 ~~~ l2 ~~~ l3 ~~~ l4 ~~~ l5 ~~~ l6

    classDef gain stroke:#16a34a,stroke-width:3px
    class r1,r2,r3,r4,r5,r6 gain
```

_The six hardening layers and what each one buys: SSH lockdown closes the front door, network scoping shrinks the surface, container limits cap the blast radius, edge controls block easy abuse, patching removes stale kernels, and scoped CI credentials enforce least privilege_

---

## Takeaways

- **The boring stuff is what gets you.** Not a zero-day — password auth plus an over-privileged account.
- **The** `docker` **group is root.** Treat any account in it like a root account: key-only, locked password, minimal exposure.
- **Don't publish internal ports**, and verify with `ss -tlnp` — Docker can punch through your firewall.
- **Layer your defenses** so no single mistake is fatal.
- **Installed ≠ running.** Reboot to activate kernel patches.
- **Least privilege for automation too** — scoped, read-only, single-purpose credentials.

Cleaning up the miner took an afternoon. Building the layers so it can't recur took a bit longer — and was the only part that actually mattered.

---

## The Series

_Building Tirraflow_ is a 5-part series on how the platform you are reading was built and run; every part is linked below.

| Part | Topic |
| --- | --- |
| [Part 1](https://tirraflow.com/posts/how-i-built-tirraflow-a-full-stack-blog-platform-from-scratch) | The stack, the architecture, and what I learned shipping it |
| [Part 2](https://tirraflow.com/posts/keycloak-silent-sso-in-react-no-loading-screens-no-redirects) | Non-blocking auth for a SPA, and why Keycloak ended up same-origin |
| [Part 3](https://tirraflow.com/posts/e2e-testing-a-react-spa-with-playwright-no-backend-required) | Testing the whole frontend with no backend at all |
| [Part 4](https://tirraflow.com/posts/cicd-auto-deploy-with-github-actions-from-push-to-production) | From push to production, and how the pipeline learned to roll back |
| **Part 5** (this post) | How it got in, and the six layers that keep it out |
