Tutorials on Linux, WordPress and web APIs

Category: Linux (page 1 of 1)

Why I Still Use Makefiles in 2026

Every few months I see someone post about their new project scaffolding tool – some npm package that wraps common tasks, or a Justfile, or a Taskfile. I’ve tried most of them. I keep coming back to Make.

Not because Make is perfect. It has real problems I’ll get to. But because it’s already installed everywhere, has zero runtime dependencies, and everyone who’s worked on a non-trivial software project has a reasonable chance of knowing how it works.

Make as a task runner, not a build tool

Most people learn Make in the context of C/C++ compilation, where it tracks file timestamps to decide what to recompile. That’s powerful, but it’s not why I use it. I use it as a simple command runner – a documented, aliased interface to the shell commands I’d otherwise have to type or remember from a README.

The GNU Make manual is comprehensive if you want to go deep, but you don’t need most of it for this use case. You need targets, variables, and .PHONY.

Here’s a Makefile I use for a typical web project (Node backend, some Docker):

.PHONY: install dev build test lint docker-up docker-down clean

NODE_ENV ?= development

install:
	npm ci

dev:
	NODE_ENV=$(NODE_ENV) npm run dev

build:
	NODE_ENV=production npm run build

test:
	npm test

lint:
	npm run lint

docker-up:
	docker compose up -d

docker-down:
	docker compose down

clean:
	rm -rf node_modules dist .next

That’s it. Now anyone cloning this repo runs make install, make dev, make build without having to know which npm scripts exist or what flags they take. The Makefile is a discoverable interface. Run make with no arguments and you get a list of available targets (if you add a default help target – I’ll come back to that).

A few patterns I find useful

Adding a help target that parses comments keeps the interface self-documenting:

help: ## Show available targets
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  %-15s %s\n", $$1, $$2}'

dev: ## Start local dev server
	npm run dev

build: ## Production build
	NODE_ENV=production npm run build

Now make help prints a formatted list. Small thing, but it means I don’t have to open the Makefile or a README to remember what’s available.

Variables with defaults are handy for environment-sensitive commands:

PORT ?= 3000
HOST ?= localhost

serve:
	python3 -m http.server $(PORT) --bind $(HOST)

PORT=8080 make serve overrides the default. This is simpler than writing a shell script with argument parsing.

The honest downsides

Make’s syntax is genuinely bad. Tabs-not-spaces is an infuriating requirement that has broken countless Makefiles when someone’s editor converted them. The implicit rules system is arcane. String manipulation is painful compared to any real scripting language. And the way Make interprets targets as filenames means you have to explicitly mark non-file targets with .PHONY, which is easy to forget.

Cross-platform is also a real limitation. On macOS, the default make is an old BSD version; you often need gmake for certain syntax. On Windows, you’re installing WSL or a compatibility layer. If your project needs to run on Windows machines without WSL, Makefile as task runner is probably the wrong call.

For complex pipelines – conditional logic, loops, file-generating tasks – I’ll reach for a shell script or a proper build tool. Make is not the answer to everything.

Another issue: recursive Make is a mess. If you have a monorepo and try to call $(MAKE) -C subdir from a top-level Makefile to run sub-project tasks, things get complicated fast. Dependency tracking between subdirectories doesn’t work the way you’d want. For monorepos I’d look at something else – Nx, Turborepo, or just writing explicit shell scripts. Make shines in simpler structures.

But for the common case – a list of 10-20 tasks that wrap CLI commands, used by a team comfortable with Linux – it’s hard to beat the combination of zero setup cost and universal availability. I switched away from it once for a Taskfile experiment and switched back six months later. The extra expressiveness wasn’t worth the “wait, what tool does this project use again?” friction.

Hardening a Fresh Ubuntu VPS: A 20-Minute Checklist

Every time I spin up a new VPS I run through the same set of steps before I put anything on it. It takes about 20 minutes and covers the most common attack vectors for an internet-facing Linux server. None of this is exotic – it’s mostly well-established practice – but having a checklist means I don’t skip a step because I’m in a hurry.

This is for Ubuntu 22.04 LTS or 24.04 LTS. Most steps work on Debian with minor adjustments.

1. Create a non-root user and lock down SSH

Log in as root one last time, create a regular user, and add it to the sudo group:

adduser deploy
usermod -aG sudo deploy

Copy your SSH public key to the new user (from your local machine):

ssh-copy-id deploy@your-server-ip

Or paste it manually into /home/deploy/.ssh/authorized_keys with permissions 700 on the directory and 600 on the file.

Now edit /etc/ssh/sshd_config and set these values:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
X11Forwarding no

Reload SSH – but don’t close your current session yet:

systemctl reload sshd

Open a new terminal and verify you can log in as your new user before closing the root session. Getting locked out here is embarrassing and fixable only through the provider’s console.

2. Configure ufw

Ubuntu ships with ufw (Uncomplicated Firewall) which wraps iptables into something manageable. Default policy is deny incoming, allow outgoing:

ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

If you’re running something else – a game server, a custom port, a mail relay – add those rules before enabling. Check status with ufw status verbose.

If SSH is on a non-standard port, use ufw allow PORT/tcp instead of ufw allow ssh. The named rule just resolves to port 22.

3. Install and configure fail2ban

fail2ban watches log files for repeated failed authentication attempts and temporarily bans the source IP via iptables. It won’t stop a determined attacker but it kills the noise from automated scanners almost completely.

apt install fail2ban -y

Create a local override file rather than editing the defaults directly:

cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Edit /etc/fail2ban/jail.local and adjust the [sshd] section:

[sshd]
enabled = true
port = ssh
maxretry = 5
bantime = 1h
findtime = 10m

Start and enable it:

systemctl enable --now fail2ban

Check ban status with fail2ban-client status sshd. See the Wikipedia article on Fail2ban for a quick overview of how the detection mechanism works.

4. Enable unattended security upgrades

apt install unattended-upgrades -y
dpkg-reconfigure --priority=low unattended-upgrades

Answer yes when prompted. This configures the system to automatically apply security updates only – not dist-upgrades or optional package updates, which is the conservative setting you want on a server.

Check the config in /etc/apt/apt.conf.d/50unattended-upgrades if you want to tweak what gets installed. The Ubuntu documentation on automatic security updates has good coverage of the available options.

5. A few sysctl tweaks

Edit /etc/sysctl.conf (or drop a file in /etc/sysctl.d/) and add:

# Disable IP source routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0

# Ignore ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0

# Enable SYN flood protection
net.ipv4.tcp_syncookies = 1

# Disable IPv6 if you're not using it
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1

Apply without rebooting:

sysctl -p

The IPv6 disable lines are optional – skip them if you’re using IPv6 or might need it. The rest are low-risk defaults that reduce the attack surface without affecting normal traffic.

Bonus: change the default SSH port

Controversial suggestion: moving SSH off port 22 doesn’t add real security, but it does dramatically reduce the noise in your auth logs, which makes it easier to spot anything genuinely suspicious. If you do this, update your ufw rule before restarting sshd:

ufw allow 2222/tcp
ufw delete allow ssh

Then edit /etc/ssh/sshd_config and change Port 22 to Port 2222 (or whatever you pick). Remember to update your SSH client config (~/.ssh/config on your local machine) with Port 2222 for the host, otherwise every login requires ssh -p 2222 deploy@host.

If you’re on a cloud provider that uses a security group layer (AWS, GCP, DigitalOcean), update that rule too – the provider’s firewall is separate from ufw and blocking port 22 at the OS level while the cloud firewall still allows it doesn’t actually close anything.

Done – for now

Twenty minutes and you’ve covered the basics: no root SSH login, key-only authentication, a firewall with a minimal open port list, automatic brute-force banning, automatic security patches, and a handful of kernel network settings. This won’t protect against every possible attack, but it handles the vast majority of automated scanning and credential stuffing attempts you’ll see against any internet-facing server. From here, hardening gets more specific to whatever you’re actually running – web server config, database access controls, AppArmor profiles, and so on.

Systemd Timers vs Cron: When to Use Which

Cron has been scheduling jobs since the 1970s and it still works fine. But if your server is already running systemd – which it almost certainly is on any modern Ubuntu, Debian, or Fedora box – you have a second option that integrates more tightly with the rest of the system. I spent a while being skeptical of systemd timers because the unit file syntax felt verbose for something cron handles in one line. Then I ran into a few real situations where that verbosity actually paid off.

Cron: the quick and familiar path

Crontab syntax is compact. Run something every day at 3am:

0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

That’s it. The job runs as the user who owns the crontab, output goes to a log file (or gets emailed if you configure MAILTO), and you manage everything with crontab -e. System-level jobs go in /etc/cron.d/ or the hourly/daily/weekly/monthly directories under /etc/cron.*.

Where cron gets annoying:

  • No native dependency tracking – if a job needs the network to be up, you handle that inside the script
  • Output handling is clunky – either redirect to a file or set up a mail relay, neither of which is great
  • No built-in catch-up for missed runs (unless you use anacron)
  • Race conditions when multiple crons overlap – you have to implement locking yourself with flock

See the Wikipedia article on Cron for a good overview of the syntax variants across platforms – there are subtle differences between Vixie cron, Busybox cron, and others.

Systemd timers: more setup, more features

A systemd timer is actually two unit files: a .timer that defines the schedule, and a .service that defines what runs. Verbose, yes. But the service file gives you dependency ordering, resource limits, sandboxing, and journal logging for free.

Example: a daily database backup. Create /etc/systemd/system/db-backup.service:

[Unit]
Description=Daily database backup
After=network.target mysql.service
Requires=mysql.service

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/db-backup.sh
StandardOutput=journal
StandardError=journal

Then /etc/systemd/system/db-backup.timer:

[Unit]
Description=Run db-backup daily at 03:00

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Enable and start it:

systemctl daemon-reload
systemctl enable --now db-backup.timer

Check the next scheduled run and recent history:

systemctl list-timers db-backup.timer
journalctl -u db-backup.service --since "24 hours ago"

A few things worth pointing out here. Persistent=true means if the machine was off at 3am, the job runs shortly after the next boot – that’s the anacron behavior built in. RandomizedDelaySec=300 spreads the load if you have many timers firing at the same clock time. And because output goes to the journal, you get structured logs you can query with journalctl instead of grepping flat files.

The After=mysql.service line is the part cron simply can’t do: the job won’t run if MySQL isn’t up. For a backup script that’s fairly important.

OnCalendar vs OnBootSec vs OnActiveSec

Systemd timers have multiple trigger types. The most common:

  • OnCalendar – wall-clock schedule, same concept as cron. Accepts human-readable strings: daily, weekly, Mon *-*-* 08:00:00, etc.
  • OnBootSec – relative to system boot. Useful for “run 5 minutes after startup”: OnBootSec=5min
  • OnActiveSec – relative to when the timer itself was last activated. Good for polling loops with variable runtime.
  • OnUnitActiveSec – relative to when the associated service last ran successfully.

Full calendar event syntax is documented at freedesktop.org/software/systemd/man/systemd.time.html. The systemd-analyze calendar command is useful for testing expressions before deploying them:

systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"

Adding resource limits and sandboxing

One underused feature of systemd service units is the ability to constrain what the job can do. If you’re running a backup script that should never use more than a certain amount of memory or CPU, you can express that directly in the unit file:

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/db-backup.sh
MemoryMax=512M
CPUQuota=25%
PrivateTmp=true
NoNewPrivileges=true

PrivateTmp=true gives the service its own /tmp mount, isolated from other processes. NoNewPrivileges=true prevents the script from gaining elevated permissions through setuid binaries. Neither of these makes sense in a crontab context – they’re systemd-native concepts.

You can go further with ProtectSystem=strict, ProtectHome=true, and ReadWritePaths= to whitelist only the directories the script needs to touch. This is overkill for a simple log rotation script but makes a lot of sense for anything that touches external network resources or processes untrusted input.

When to use which

Use cron when: the task is simple, you’re already managing a pile of crontabs and don’t want to introduce another system, you’re on a minimal container image without full systemd, or the person maintaining the server after you is more comfortable with crontab than unit files.

Use systemd timers when: you need the job to run as a specific user with resource limits (memory cap, CPU quota), you want journal logging without redirecting to a file, the job has a systemd service dependency (After=, Requires=), or you want missed-run catch-up without installing anacron separately.

For most things on a typical Linux server, either works. I tend to reach for cron for quick one-liners and systemd timers for anything that runs as part of a broader service stack. If you’re already managing the backup or maintenance script as a systemd service for other reasons, the timer is the natural companion – you already have the unit file, you just need to add a schedule.