The port nobody remembered
Most successful attacks on small and mid-sized companies don't need a genius. They need one port open to the world that should never have been open, and a bot that scans the entire internet in a few hours.
I recently saw this scenario up close. No zero-day, no sophisticated phishing, just a service reachable from outside that was only ever meant to listen inside the network. Afterwards, everyone knew how it could have been prevented.
On a typical Laravel server the most common way this happens is mundane: a ports: "6379:6379" line in docker-compose.yml. Docker writes its own iptables rules, so the port is public even though ufw status says incoming traffic is denied. More on that below, because it's the one item on this list people refuse to believe until they scan their own box.
Who this is for: developers and small teams who run their own applications (usually Laravel on a VPS or in the cloud) and have no dedicated security team.
What you'll get:
- the 12 mistakes behind most breaches of small teams
- the server and network layer in detail, including the Docker trap
- the application and data layers as a short checklist, with links to the deep dives
- a way to detect an attack before your customer does, including a 10-line daily port diff
- a plan for the first 72 hours of an incident, including GDPR and NIS2 reporting duties
- a printable checklist (also as a PDF download)
Four principles
Every recommendation further down follows from one of these four.
- Minimal attack surface. What you don't expose can't be attacked. Every open port, public endpoint and installed package is a potential way in.
- Defense in depth. Assume every single layer will fail. A firewall doesn't replace authorization in the app, and authorization doesn't replace encrypted backups.
- Least privilege. The app's database user doesn't need
DROP. An API key for sending email doesn't need access to your whole cloud account. PHP doesn't need to run as root. - Assume breach. Ask how fast you'll notice and how little they'll get. That's why logs, alerts and tested backups matter as much as the firewall.
internet
│
▼
[ 1. network and server ] firewall, closed ports, SSH, updates ┐
│ │
▼ │ detection:
[ 2. application ] authorization, validation, rate limits │ logs, alerts,
│ │ daily scans
▼ │
[ 3. data ] secrets, encryption, 3-2-1 backups ┘
An attacker has to get through every layer in turn. Detection watches all three.
Top 12 mistakes that get companies breached
Most incidents at small companies start with one of these. The first six are infrastructure, the next six are the application and processes.
| # | Mistake | Why it's dangerous | Fix |
|---|---|---|---|
| 1 | Database, Redis, Elasticsearch or an admin panel reachable from the internet | bots scan all of IPv4 in hours; Redis without a password often means remote code execution | listen on 127.0.0.1 or a private network, firewall set to deny by default |
| 2 | ports: "6379:6379" in Docker Compose | Docker writes its own iptables rules and bypasses UFW; the port is public even though the firewall "blocks" it | 127.0.0.1:6379:6379, or no ports and an internal Docker network |
| 3 | SSH with password login, root allowed | brute force runs around the clock; a weak password is only a matter of time | keys only, PermitRootLogin no, fail2ban or CrowdSec, ideally SSH over VPN only |
| 4 | No system or image updates | known vulnerabilities get public exploits within days of disclosure | unattended-upgrades, regular image rebuilds, alerts on new versions |
| 5 | Web root set to the project directory instead of public/ | /.env and /.git/ downloadable from a browser, with passwords and source code | web server root always on public/, explicit block for dotfiles |
| 6 | APP_DEBUG=true in production | stack traces with config, paths and code; with facade/ignition before 2.5.2 (CVE-2021-3129) a debug page even led to remote code execution | APP_ENV=production, APP_DEBUG=false, checked in the pipeline before deploy |
| 7 | Secrets in the repository (.env, API keys, APP_KEY) | a leaked repo leaks everything; a leaked APP_KEY lets attackers forge encrypted cookies | .env in .gitignore, secret scanner in CI (e.g. gitleaks), secrets in a secrets manager |
| 8 | Missing object-level authorization (IDOR) | /invoices/124 instead of /invoices/123 and you see someone else's data | a Policy per model, Gate::authorize() (or $this->authorize() with the AuthorizesRequests trait), scoped route bindings |
| 9 | Mass assignment | a user adds is_admin=1 to a form and gets admin rights | $fillable, save only $request->validated() |
| 10 | No rate limiting on login and API | password brute force, credential stuffing, account enumeration | throttle on login, password reset and API routes |
| 11 | Backups on the same server and never tested | ransomware encrypts data and backups at once; or the backup turns out to be empty | 3-2-1 rule, an immutable or offline copy, a restore test every quarter |
| 12 | No logs or alerts | the attack runs for weeks and you learn about it from a customer or a leak | central logs, alerts on anomalies, regular external scans |
If you only have time for one thing after reading this: scan your servers from the outside (next section) and compare the result with what you believe should be open.
Layer 1: network and server
The goal of this layer is simple: from the internet you see only 80 and 443. Everything else is closed or reachable only over VPN.
Check what is actually open
Start with a scan from outside, from a machine outside your network. That's how an attacker sees you.
# full TCP scan of all ports
nmap -Pn -p- --min-rate 1000 YOUR_IP_ADDRESS
# service versions on the ports found
nmap -Pn -sV -p 22,80,443,3306,5432,6379,9200 YOUR_IP_ADDRESS
Also look up your IP addresses on Shodan or Censys. If your Redis or Elasticsearch shows up there, the bots found it long ago.
On the server itself, list everything that is not bound to loopback, so you see sockets on 0.0.0.0, [::] and public addresses:
ss -tulpn | grep -vE '127\.0\.0\.[0-9]+|\[::1\]'
Firewall: deny by default
ufw default deny incoming
ufw default allow outgoing
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow from VPN_ADDRESS to any port 22 proto tcp
ufw enable
Careful: if you run this over SSH and the VPN isn't up yet, ufw enable locks you out. Bring the VPN up first, or temporarily allow 22 from your current IP and remove that rule once you've confirmed SSH over the VPN works. Keep the provider's web console open as a way back in.
In the cloud, set the same rules in security groups or your provider's firewall. It's a second, independent layer: a mistake in one doesn't open the server.
The Docker trap
A line like ports: "5432:5432" in docker-compose.yml publishes the port on all interfaces. Docker inserts its own iptables rules ahead of the ones UFW manages, so UFW doesn't block them, even if ufw status says otherwise. The external scan from the previous section is the only check that tells the truth here.
services:
app:
image: your-app
networks: [frontend, backend]
db:
image: postgres:16
# bad: public port despite the firewall
# ports: ["5432:5432"]
# good: no ports at all, reachable only from the backend network
networks: [backend]
redis:
image: redis:7
# same here: no ports, reachable only from the backend network
networks: [backend]
networks:
frontend:
backend:
internal: true
internal: true also cuts the backend network off from outbound internet access, which is what you want for a database and a cache. It also means published ports don't work on that network. If you need host access to the database (an SSH tunnel), attach it to an additional non-internal network and publish it as 127.0.0.1:5432:5432, never 5432:5432. If a container really has to be public, filter its traffic in the DOCKER-USER chain, not in UFW.
SSH, updates and the rest of the baseline
Keys only (PasswordAuthentication no, PermitRootLogin no, an AllowUsers list), fail2ban or CrowdSec, and ideally SSH reachable only through a VPN (WireGuard, Tailscale) or a bastion. Moving SSH to a non-standard port reduces log noise, but it isn't a security control. Turn on unattended-upgrades for security patches and rebuild Docker images on a schedule, not only when code changes: a year-old FROM php:8.3-fpm means a year of unpatched libraries.
The full server setup, with users, systemd, journald and recovery, is in Linux server baseline for DevOps work.
Services that should never be public
MySQL and PostgreSQL, Redis, Memcached, Elasticsearch and OpenSearch, RabbitMQ, the Docker API (2375/2376), admin panels (phpMyAdmin, Horizon, Telescope, Grafana, Portainer), queue and monitoring dashboards. If you need them from outside: VPN or an SSH tunnel, plus a separate login.
Layer 2: the Laravel application
Laravel ships with good defaults: CSRF protection, escaping in Blade, parameter binding in queries. Most vulnerabilities in Laravel apps appear when someone works around these mechanisms or forgets about authorization. The deep dive with code for each point is Secure a Laravel application: OWASP risks and practical defences. Here is the short version.
The gap that shows up most often is object-level authorization. Authentication says who you are; authorization says what you can access.
// bad: any logged-in user sees any invoice
public function show(Invoice $invoice)
{
return view('invoices.show', compact('invoice'));
}
// good: a Policy checks ownership
public function show(Invoice $invoice)
{
Gate::authorize('view', $invoice);
return view('invoices.show', compact('invoice'));
}
Rule of thumb: every endpoint that takes an identifier has a test proving that another user gets a 403. For nested resources use scopeBindings(), so /teams/1/projects/5 can't return a project from a different team.
The rest, as a checklist:
- Production config:
APP_ENV=production,APP_DEBUG=false,SESSION_SECURE_COOKIE=true, and a CI step that fails the deploy if debug is on. Telescope, Horizon and Debugbar behind a gate or disabled outside local. - Mass assignment: save only
$request->validated(), never$request->all(); define$fillable; enableModel::preventSilentlyDiscardingAttributes()in development. - SQL injection:
DB::raw(),whereRaw()andorderByRaw()always with bindings; sort columns from the request checked against an allowlist. - XSS:
{!! !!}in Blade anddangerouslySetInnerHTMLin React only for HTML sanitized on the server. - Rate limiting:
throttleon login, password reset, registration and 2FA verification; a per-user or per-token limit on every API. - Uploads: validate type and size, store on a private disk, serve through a controller with authorization, and make sure the web server never executes PHP from upload directories.
- Headers: HSTS, a CSP rolled out report-only first,
X-Content-Type-Options: nosniff,frame-ancestors 'none'. - Dependencies:
composer auditandnpm audit --omit=devon every pull request, plus Dependabot or Renovate. Most vulnerable apps don't have a bug in their own code, but in a package from two years ago.
Layer 3: secrets, data and backups
This layer decides how much damage is done when the first two fail.
Secrets
.envnever goes into the repository. Add a secret scanner to CI (gitleaks, trufflehog) that blocks commits containing keys.- Separate keys per environment. A production key never lands on a developer's laptop.
- API keys with minimal scope: an S3 token for one bucket only, a payment gateway token without admin rights.
- A list of all secrets and how to rotate them, written down in advance. During an incident there's no time to hunt for where the Mailgun key lives.
APP_KEYencrypts cookies and data, so a leak is serious. Rotating it invalidates sessions and data encrypted with the old key, which is why Laravel lets you list previous keys inAPP_PREVIOUS_KEYSduring the migration.
Data
- Encryption in transit everywhere: HTTPS, and TLS to the database if it runs on another machine.
- Especially sensitive fields (national ID numbers, health data, tokens) encrypted at rest, e.g. with Eloquent's
encryptedcast. - Keep as little data as you can. What you don't store can't leak.
- The app's database account can't change the schema in production. Migrations run from a separate account in the pipeline.
Backups: the 3-2-1 rule plus a test
- 3 copies of your data, on 2 different media or with two providers, 1 outside your main infrastructure.
- At least one immutable copy (S3 Object Lock, an offline backup). Ransomware encrypts everything it can reach, including backups mounted on the server.
- Encrypted copies, with the key stored somewhere other than the backup.
- A restore test every quarter. A backup you've never restored is a hypothesis. Measure how long a full restore takes: that's your real downtime.
Detecting attacks before your customer does
You can't detect what you don't log, and you won't react to a log nobody reads. You need three things: the right events in your logs, one place where they all land, and alerts that wake up a human.
What to log
| Source | Events | Example attack signal |
|---|---|---|
| Application | failed and successful logins, password and permission changes, 403s on resources, data exports | 50 failed logins on one account in 5 minutes; a run of 403s across sequential IDs |
| Web server | 4xx/5xx statuses, paths, User-Agent | requests for /.env, /.git/config, /wp-login.php, /vendor/phpunit |
| System | SSH logins, sudo, new users, cron and systemd changes | an SSH login from a new country at 3 a.m. |
| Database | new connections, slow and bulk queries | SELECT * on the users table from outside the app |
| Network | open ports, unusual outbound traffic | a new port in the scan; the server sending gigabytes to an unknown address |
In Laravel you get login events for free: listen for Illuminate\Auth\Events\Failed, Login and Lockout and write them to a dedicated log channel with the IP address and user ID. Never log passwords, tokens or full document numbers.
One place, and alerts
- Logs from every server in one system (Grafana Loki, Graylog, ELK or a cloud service). An attacker with server access will start by wiping local logs.
- Alerts on a few specific rules rather than on everything. Too many alerts end with nobody reading them.
- To start: a spike in failed logins, requests to sensitive paths, SSH logins from outside the VPN, a newly open port, a spike in outbound traffic.
Signs someone is already inside
- new system accounts or new keys in
~/.ssh/authorized_keys - unknown entries in
crontab -land new systemd services - PHP files in upload directories or in
storage/andpublic/(web shells) - high-CPU processes with odd names (crypto miners are a common first payload)
- changed application files that didn't come through a deploy
A daily port diff
This is the alert that catches a port opened by mistake, and it fits in ten lines. Run it from a machine outside the server's network (a small VPS, a CI runner on a schedule), never from the server itself.
#!/usr/bin/env bash
# port-diff.sh HOST: alert when the set of open TCP ports on HOST changes
set -euo pipefail
host="$1"
baseline="$HOME/port-diff/$host.baseline"
current="$(nmap -Pn -p- --min-rate 1000 -oG - "$host" | grep -oE '[0-9]+/open' | sort -u)"
if [[ "$current" != "$(cat "$baseline")" ]]; then
printf 'Open ports on %s changed:\n%s\n' "$host" "$current" \
| mail -s "port-diff: $host" [email protected]
fi
Create the baseline once, after you've closed everything you meant to close: mkdir -p ~/port-diff && nmap -Pn -p- -oG - HOST | grep -oE '[0-9]+/open' | sort -u > ~/port-diff/HOST.baseline. Then a daily cron entry, and swap mail for whatever reaches your team (a Slack webhook, a ticket).
Two more automations worth the setup time:
- File integrity monitoring, e.g. Wazuh or AIDE, alerting when application files change outside a deploy.
- Uptime monitoring (Uptime Kuma, Better Stack), because an attack often shows up first as a slowdown.
When it already happened: the first 72 hours
Nobody thinks clearly during an incident. That's why the plan is written beforehand, and in a crisis you only execute it.
- Don't panic and don't wipe. Before fixing anything, snapshot the disk and preserve the logs. A quick server reinstall destroys evidence and won't tell you how the attacker got in.
- Isolate. Cut the affected machine off the network (security group, firewall), but don't power it off if you can avoid it: RAM is evidence too.
- Close the way in. Find and close the entry point: the port, the vulnerability, the stolen password. Without this, the attacker comes back after you restore.
- Rotate every secret. Database passwords, API keys,
APP_KEY, CI tokens, SSH keys. Assume everything that was on the server has leaked. - Hunt for backdoors. Attackers leave themselves a way back: accounts, SSH keys, cron entries, systemd services, web shells. The safest path is rebuilding the server from a clean image and restoring data from a backup taken before the attack.
- Assess what leaked. Which data, whose, from what period. Your legal obligations below depend on it.
- Report if you must. See the table below. Deadlines run from the moment you became aware, not from the end of your analysis.
- Inform. Customers and users, if they are at risk. Short, specific, with what they should do (e.g. change their password).
- Write a blameless post-mortem. Timeline, root cause, what worked, what didn't, action items with owners and dates.
Legal obligations in the EU
| Regulation | When it applies | Who to notify and when |
|---|---|---|
| GDPR | a breach or loss of personal data that poses a risk to people | your data protection supervisory authority within 72 h; the people affected when the risk is high |
| NIS2 (via national law) | your company is an essential or important entity (e.g. healthcare, energy, transport, digital infrastructure) and the incident is significant | the national CSIRT or competent authority: early warning within 24 h, notification within 72 h, final report within a month |
These duties are independent: a personal data breach at a company covered by NIS2 means reporting to both the CSIRT and the data protection authority. Each EU country implements NIS2 through its own law. In Poland it's the amended National Cybersecurity System Act, in force since April 3, 2026, and covered entities have until April 3, 2027 to bring their systems and procedures in line. Whether and how your company is covered is a question for a lawyer or your compliance team. Outside the EU, check your local breach-notification rules.
Cheat sheet: printable checklist
Go through it once a quarter for every server and every application. The same list is available as a one-page PDF under the article title.
Server and network
- An external
nmap -p-scan shows only 80 and 443 - The firewall denies incoming traffic by default, in the cloud panel too
- No
ports:entry in Docker Compose publishes a database, Redis or panel on0.0.0.0 - SSH keys only, root disabled, access via VPN or bastion
- Automatic security updates on, images rebuilt within the last month
- Admin panels (phpMyAdmin, Horizon, Telescope, Grafana) not publicly reachable
Application
-
APP_ENV=production,APP_DEBUG=false - Web root points to
public/;/.envand/.git/return 404 - Every endpoint with an identifier has a Policy and a 403 test for another user
- Models saved only from
validated(), models define$fillable - No
whereRaw/DB::rawwith request input and no binding - Rate limiting on login, password reset, registration and API
- Uploads validated, on a private disk, no PHP execution
- HSTS, CSP,
nosniffandframe-ancestorsheaders set - Session cookies with
Secure,HttpOnly,SameSite
CI/CD and dependencies
-
composer auditandnpm auditon every pull request - Dependabot or Renovate enabled
- A secret scanner blocks commits with keys
- The pipeline blocks deploys with
APP_DEBUG=true
Data and backups
- 3-2-1 backups, including one immutable or offline copy
- Last full restore test: within the last quarter
- Sensitive fields encrypted at rest
- List of secrets and rotation procedure written down
Detection and response
- Application, web server and system logs in one place
- Alerts: failed logins, sensitive paths, SSH from outside the VPN, new open port
- A daily automated external port scan
- Incident response plan written down, with contacts and reporting deadlines (data protection authority 72 h, CSIRT 24 h)
- It's clear who on the team makes decisions during an incident
Prevention over heroics
You don't have to do everything at once. The order that gives the most for the least effort:
- An external scan, and closing everything except 80 and 443.
- SSH keys only, over VPN.
APP_DEBUG=false, a correct web root,composer auditin CI.- Policies and authorization tests on every endpoint with an identifier.
- 3-2-1 backups with a restore test.
- Central logs, the daily port diff and four more basic alerts.
What about you? Which of these 12 mistakes have you seen in production? Tell me in the comments. What surprises me most is how often the Docker one comes back.
If you'd rather have someone go through this checklist against your servers and code, or you're in the middle of an incident and need a second pair of hands, that's work I take on for companies and software houses. Get in touch.
Sources and further reading
- Poland's amended National Cybersecurity System Act: deadlines and duties (Forsal, in Polish)
- NIS2 and GDPR: two notifications for a data breach (Legiscope, in Polish)
- Alice and Bob Learn Secure Coding, Tanya Janca (O'Reilly)
- Zero Trust Networks, Evan Gilman and Doug Barth
- Defensive Security Handbook, 2nd Ed., Lee Brotherston and Amanda Berlin