Self-Hosting a Small Production Stack: Linux, Backups, Hardening and Recovery
· updated · self-hosting, linux, homelab, devops, backups, recovery, cybersecurity, nginx, docker, matrix, nextcloud, monitoring
I have been self-hosting for many years. What started as a way to learn Linux, web servers and networking gradually became a long-running personal infrastructure environment: websites, personal cloud storage, code hosting, documentation, communication tools, file services, monitoring and automation.
I do not see this setup as a toy server. I treat it as a small production environment: documented, recoverable, monitored and hardened enough that failures become engineering problems rather than emergencies.
This post is a summary of that approach. The point is not to publish my infrastructure map. The point is to show the engineering discipline behind running personal services over a long period of time.
Architecture overview
The infrastructure follows a simple layered model:
Public Internet
│
▼
DNS + TLS
│
▼
Nginx reverse proxy
│
├── Public websites
├── Personal cloud services
├── Documentation/wiki services
├── Code hosting
├── Communication services
├── Notification services
└── Health/status endpoints
Storage layer
├── OS disk
├── bulk data disk
├── encrypted service storage
└── external/offline backup media
Operations layer
├── backup scripts
├── restore procedures
├── service maintenance scripts
├── cron/systemd scheduled jobs
├── health checks
└── notifications
The stack mixes traditional Linux services and containerised applications. I do not containerise everything by default. Some services are easier to run and maintain as normal Linux services; others benefit from Docker or Docker Compose because they have multiple moving parts, isolated dependencies or a repeatable deployment model.
The reverse proxy is the main public entry point. Internally, services are separated by application, user, directory, port and deployment model. This keeps the public surface smaller and allows each service to evolve without exposing every internal component directly.
Services I operate
The environment has included several categories of service:
- public and private websites;
- my personal website and CV/blog publishing platform;
- private file synchronisation and cloud storage;
- wiki/documentation systems;
- self-hosted code repositories;
- Matrix/Synapse-based communication services;
- mail-related services and backup workflows;
- SFTP-style file libraries;
- notification services;
- health/status pages;
- operational scripts for backups, updates, users and maintenance.
This combination is useful because it exercises many different parts of real systems work:
- HTTP routing and TLS termination;
- Linux users and permissions;
- database administration;
- storage layout;
- encrypted volumes;
- stateful container management;
- backup consistency;
- service monitoring;
- scriptable administration;
- upgrade planning;
- recovery documentation.
A homelab becomes much more valuable when it stops being only a collection of installed applications and becomes an operated system.
Reverse proxy and web serving
Most public traffic is handled through Nginx. The reverse proxy layer is responsible for:
- routing domains and subdomains to the right upstream service;
- terminating HTTPS;
- enforcing HTTP-to-HTTPS redirects;
- applying security headers;
- hiding internal ports;
- blocking obvious leak paths;
- separating public websites from internal services;
- making service migrations less disruptive.
A simplified public-safe reverse proxy pattern looks like this:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location ~ /\.(env|git|svn|hg) {
deny all;
}
location / {
proxy_pass http://127.0.0.1:PORT;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
The important idea is not that every vhost looks exactly like this. The important idea is that the proxy becomes a security and operations boundary. Services can bind locally, expose only what they need, and move internally without changing the public contract.
Web development and publishing stack
My own websites are also part of the infrastructure. They are not only static pages; they involve application code, styling, content management, CV generation, blog publishing, deployment and server administration.
The stack has involved:
- PHP and PHP-FPM;
- SQLite and other databases where appropriate;
- HTML, CSS and JavaScript;
- Markdown-based content;
- Git versioning;
- Nginx vhosts;
- TLS certificates;
- SEO metadata;
- responsive layouts;
- accessibility considerations;
- privacy-aware analytics or minimal tracking;
- server-side generation of public pages and PDFs.
This is useful because it connects software development to operations. A personal website is not just a design exercise. It needs routing, backups, deployment, caching, permissions, TLS, error handling, content editing and long-term maintainability.
Stateful data and filesystem layout
One of the main lessons from self-hosting is that state matters more than the application binaries.
Applications can usually be reinstalled. Stateful data is what must be protected:
- uploaded files;
- user data;
- databases;
- mail data;
- code repositories;
- wiki pages;
- configuration;
- certificates;
- secrets;
- encryption keys;
- operational scripts;
- restore documentation.
For that reason, I organise the system so that stateful data, service configuration and operational scripts are easier to identify and back up. A clean layout makes recovery possible because it answers a basic question: “What do I need in order to rebuild this service?”
A public-safe example of that mental model is:
/system
OS packages, users, base configuration
/services
application code, containers, service definitions
/data
uploaded files, cloud storage, repositories, service state
/databases
SQL dumps or database-backed service state
/secrets
keys, tokens, credentials, encryption material
/operations
backup scripts, maintenance scripts, restore notes
The real system is more specific than this, but the principle is stable: state should not be scattered randomly across the server without documentation.
Encrypted storage for sensitive services
Some services benefit from encrypted storage. In my case, part of the communication stack is stored inside a LUKS-backed encrypted volume. That encrypted filesystem contains the service data and its database state.
The important design points are:
- the encrypted container is part of the normal backup set;
- the key material is handled separately from ordinary backups;
- the restore procedure includes how to unlock and inspect the encrypted data;
- a header backup is kept separately for catastrophic LUKS metadata failure;
- recovery can be tested without touching the live service.
A simplified version of the idea is:
encrypted-service.luks
├── service-data/
├── database/
├── config/
└── docker-compose.yml
The backup contains the encrypted image. The sensitive key material is copied separately and should be moved to safer storage, such as an offline disk or encrypted vault. This separation matters: a backup of encrypted data is not enough if the key is lost, but storing the key casually next to the data weakens the whole design.
Backup strategy
My backup approach is built around the idea that backups must be restorable, not merely present.
A typical full backup needs to cover:
- filesystem data;
- service directories;
- application code;
- configuration files;
- database dumps;
- container state where relevant;
- cron and systemd definitions;
- web server configuration;
- TLS-related material, with care;
- operational scripts;
- restore documentation.
For SQL-backed services, the backup should include proper dumps rather than relying only on copying live database files. For file-backed services, rsync-style backups work well, especially when combined with a dated retention area for changed or deleted files.
A simplified backup flow looks like this:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_ROOT="/mnt/backup/current"
DATE="$(date +%F)"
mount_backup_disk
notify "backup started"
dump_databases "$BACKUP_ROOT/dbs.sql"
enable_maintenance_mode_for_stateful_web_apps
rsync -aAX --delete \
--backup --backup-dir="$BACKUP_ROOT/OLD/$DATE" \
/data/ "$BACKUP_ROOT/data/"
rsync -aAX --delete \
/services/ "$BACKUP_ROOT/services/"
rsync -aAX --delete \
/operations/ "$BACKUP_ROOT/operations/"
copy_selected_system_config "$BACKUP_ROOT/system-config/"
disable_maintenance_mode_for_stateful_web_apps
write_backup_marker "$BACKUP_ROOT/when.txt"
unmount_backup_disk
notify "backup completed"
This is not intended to be a copy-paste script. It shows the operational pattern:
- mount backup media;
- notify start;
- dump databases;
- pause or stabilise stateful services where appropriate;
- copy state and configuration;
- preserve deleted/changed files in a dated area;
- write a backup marker;
- unmount and power off backup media;
- notify completion.
The backup disk should not be treated as always-online storage. Keeping it mounted all the time increases exposure to accidental deletion, filesystem corruption and ransomware-style failure modes.
Sensitive material backup
Sensitive material is handled separately from ordinary backups. This includes things like:
- LUKS keyfiles;
- environment files;
- service tokens;
- private keys;
- SSH host keys, if chosen;
- TLS private keys, if chosen.
The operational rule is simple: ordinary backup data and sensitive key material should not have exactly the same handling.
A simplified sensitive-backup flow looks like this:
#!/usr/bin/env bash
set -euo pipefail
SENSITIVE_BACKUP="/mnt/backup/sensitive"
install -d -m 700 "$SENSITIVE_BACKUP/luks"
install -d -m 700 "$SENSITIVE_BACKUP/env"
install -d -m 700 "$SENSITIVE_BACKUP/ssh"
install -m 600 /secure/keyfile "$SENSITIVE_BACKUP/luks/service.key"
install -m 600 /root/.config/service.env "$SENSITIVE_BACKUP/env/service.env"
sync
After that, the sensitive backup should be moved to safer storage, such as an offline encrypted medium or an encrypted vault. This makes the recovery process possible without casually exposing the keys that make recovery possible.
Recovery testing and offline inspection
The best backup strategy is one that can be tested without touching production.
For encrypted service storage, a useful pattern is read-only inspection from backup:
cryptsetup open --readonly \
/path/to/backup/encrypted-service.luks \
service_inspect \
--key-file /path/to/secure/keyfile
mount -t ext4 -o ro,norecovery \
/dev/mapper/service_inspect \
/mnt/service_inspect
ls -lah /mnt/service_inspect
umount /mnt/service_inspect
cryptsetup close service_inspect
The ro,norecovery part is important for an ext4 filesystem that may look unclean because it was copied while not formally unmounted. The goal is inspection, not mutation. I want to be able to verify that the expected service data, database files and configuration are present without replaying a journal or writing to the backup image.
This is the kind of operational detail that makes a backup plan real. A restore checklist should explain not only “what is backed up” but also “how to inspect it safely”.
Service administration scripts
Over time, I have built and maintained scripts for recurring administrative tasks.
Examples of what these scripts handle:
- full backups;
- sensitive-key backups;
- system updates;
- service-specific updates;
- dynamic DNS updates;
- certificate renewal;
- health checks;
- user provisioning for hosted sites;
- password/default-credential checks;
- Matrix/Synapse administration;
- SFTP library users;
- notification publishing;
- service certificate updates.
This matters because scripts turn one-off server knowledge into repeatable operations. A good admin script captures:
- expected inputs;
- expected file locations;
- safety checks;
- error handling;
- service reloads;
- notification behaviour;
- the difference between enabling, disabling and purging a service.
For example, a hosting-management script can make the difference between manually editing Nginx, PHP-FPM, users and directories every time, and having a repeatable workflow such as:
hosting-admin create project community-only
hosting-admin php-enable project
hosting-admin add-domain project example.com www.example.com
hosting-admin disable project
hosting-admin purge project
The exact script names are not important publicly. The engineering habit is important: reduce undocumented manual operations, make repeated tasks explicit, and keep destructive actions clear.
Cron and systemd timers
Scheduled maintenance is another important part of the stack.
Typical scheduled tasks include:
- certificate renewal;
- dynamic DNS updates;
- service backups;
- health checks;
- service-specific update checks;
- imports or indexing jobs;
- log maintenance;
- notifications.
For simple jobs, cron is often enough. For jobs that benefit from dependency management, logging and service integration, systemd timers are cleaner.
A simplified systemd timer pair might look like this:
# /etc/systemd/system/example-health.service
[Unit]
Description=Run example health check
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/example-health.sh
# /etc/systemd/system/example-health.timer
[Unit]
Description=Run example health check periodically
[Timer]
OnBootSec=5min
OnUnitActiveSec=10min
Persistent=true
[Install]
WantedBy=timers.target
The value is not just automation. It is visibility. Failed systemd units are easier to inspect than silent manual habits.
Health checks and monitoring
For a personal infrastructure stack, monitoring should be lightweight but real.
The monitoring layer should answer questions such as:
- Are the public services reachable?
- Are local services running?
- Are reverse proxy upstreams responding?
- Are backups recent?
- Is the backup marker fresh?
- Are disks mounted as expected?
- Is storage usage within safe limits?
- Are certificates close to expiry?
- Are scheduled jobs failing?
- Are there repeated authentication failures?
- Are database dumps being produced?
- Are logs rotating correctly?
- Are SMART/disk-health indicators acceptable?
A simple health script can output JSON for a private or public status page:
{
"host": "server",
"checked_at": "2026-06-22T18:00:00Z",
"services": {
"nginx": "ok",
"php-fpm": "ok",
"database": "ok",
"backup_age_hours": 12,
"disk_usage_percent": 71,
"tls_days_remaining": 64
}
}
Even a small status page is useful because it turns vague worry into concrete signals. It also makes it easier to notice when a maintenance task silently stopped working.
Hardening principles
The hardening approach is pragmatic rather than theatrical. The goal is to reduce obvious risk and make compromise or misconfiguration less likely.
The principles I apply include:
- expose as few ports as possible;
- keep most services behind Nginx;
- bind internal services to localhost where possible;
- use HTTPS by default;
- renew certificates automatically;
- separate Unix users for different services;
- avoid shared write permissions;
- keep secrets out of web roots and repositories;
- block access to dotfiles and environment files;
- use SSH keys rather than password-based administration where possible;
- check for default credentials after provisioning;
- use fail2ban or equivalent protections where appropriate;
- keep service configuration documented;
- monitor logs for repeated failures;
- avoid leaving old applications publicly reachable;
- test restore paths before they are urgently needed.
Security is not a single tool. It is the combination of boundaries, defaults, permissions, updates, observability and recovery.
Example: preventing accidental secret exposure
A recurring class of web-server mistakes is accidentally serving files that were never meant to be public: .env, .git, backups, lockfiles, local configs or editor artefacts.
A generic Nginx defensive block can help:
location ~ /\.(?!well-known) {
deny all;
}
location ~* \.(env|ini|log|sql|bak|old|orig|swp|lock)$ {
deny all;
}
location ~* /(composer\.(json|lock)|package-lock\.json|yarn\.lock|Dockerfile|docker-compose\.ya?ml)$ {
deny all;
}
This does not replace correct deployment practices, but it adds a useful guardrail. Public web roots should contain public assets, not source repositories, secrets or operational files.
Example: backup freshness check
Backups should produce machine-checkable evidence that they ran.
A simple marker-based check can be enough:
#!/usr/bin/env bash
set -euo pipefail
MARKER="/backup/when.txt"
MAX_AGE_HOURS=36
if [[ ! -f "$MARKER" ]]; then
echo "backup marker missing"
exit 2
fi
last_backup_epoch="$(stat -c %Y "$MARKER")"
now_epoch="$(date +%s)"
age_hours="$(( (now_epoch - last_backup_epoch) / 3600 ))"
if (( age_hours > MAX_AGE_HOURS )); then
echo "backup too old: ${age_hours}h"
exit 2
fi
echo "backup fresh: ${age_hours}h"
This is not sophisticated monitoring, but it catches a very common failure: assuming backups are running because a script exists.
Database handling
Database-backed services need special treatment.
Copying a live database directory is not always enough. Depending on the database and service, the backup plan may need:
- logical dumps;
- maintenance mode;
- service stop/start windows;
- filesystem snapshots;
- database-specific backup tools;
- restore tests;
- schema migration awareness;
- version compatibility notes.
For MariaDB/MySQL-style services, a logical dump can be part of the normal backup process:
mysqldump --single-transaction --routines --triggers --events \
--all-databases > dbs.sql
For PostgreSQL-backed services, the equivalent might be service-specific dumps or a documented filesystem-level backup strategy if the database lives inside a service container or encrypted volume.
The key point is that “database exists somewhere under the service directory” is not a backup strategy. The backup method should match the database engine and the consistency requirements of the service.
Documentation as operational memory
The most valuable improvement in the whole setup has been documentation.
Good infrastructure notes answer:
- what services exist;
- where state lives;
- which services are containerised;
- which services use external databases;
- which ports should be public;
- which files are secrets;
- how backups are produced;
- how sensitive keys are backed up;
- how to inspect encrypted backups;
- how to restore the main services;
- which scripts exist;
- which scheduled jobs exist;
- what normal maintenance looks like;
- what to check after a reboot;
- what to check after a restore.
This kind of documentation is not glamorous, but it is what turns a personal server from a fragile pet into an understandable system.
What I learned from operating it
Self-hosting has taught me practical lessons that transfer directly to professional software engineering:
- features are only one part of software work;
- deployment and operations shape application design;
- state is usually more important than code;
- backups need restore procedures;
- documentation should be written before an emergency;
- security depends on defaults and boundaries;
- observability matters even at small scale;
- automation should remove repeated manual risk;
- simple systems are easier to recover;
- every service adds maintenance cost;
- private infrastructure should not expose more than necessary.
This mindset also changes how I build applications. I care about configuration, logs, migrations, database backups, deployment paths, service boundaries and failure modes because I have had to operate systems myself.
Technologies and practices involved
The stack has changed over time, but the work has involved:
- Linux server administration;
- Debian/openSUSE-style system administration;
- Nginx reverse proxying and TLS termination;
- PHP, PHP-FPM, HTML, CSS and JavaScript;
- SQLite, MariaDB/MySQL and PostgreSQL-backed services;
- Docker and Docker Compose;
- Git and self-hosted repositories;
- Nextcloud;
- DokuWiki;
- Matrix/Synapse;
- Mailcow-style mail operations and backups;
- SFTP-style file services;
- Let’s Encrypt certificates;
- LUKS encrypted storage;
- rsync-based backups;
- database dumps;
- cron jobs;
- systemd services and timers;
- Bash scripting;
- DDNS;
- DNS and domain management;
- SSH administration;
- service health checks;
- notification hooks;
- backup/restore documentation;
- public-safe technical writing.
Conclusion
This infrastructure is personal, but the engineering practice is real.
It has required me to design, deploy, secure, monitor, document and recover systems over a long period of time. It has involved web development, Linux administration, databases, reverse proxying, encrypted storage, automation, backups, monitoring and operational discipline.
That is why I consider self-hosting part of my technical experience. It is not just “running a server”. It is a continuous exercise in full-stack engineering, DevOps, cybersecurity fundamentals and long-term maintainability.