I’ve been seriously working on personal self-hosting for about five or six years now. If I’m being precise, the point when I truly started managing my own VPS can be traced even further back. The original motivation was actually very simple: there were services I used every single day, such as a password manager, music streaming, note-taking, a personal website, code repositories, and so on. Given that, why not just deploy them myself? That way, the data stays in my own hands, and I’m no longer at the mercy of subscriptions and price increases from various platforms.
Over the years, to be honest, self-hosting has not been effortless. I have to maintain the system, back up data, pay attention to security, and occasionally deal with emergencies. But the return is also very clear:
- Complete control over my data
- No longer being tied to subscription pricing
- A much deeper understanding of systems, networking, and security
At this point, I’m running more than a dozen services on a single server, with costs amounting to just that one server. After canceling quite a few third-party subscriptions over the past two years, the overall cost may actually be lower. For me, this state of “complex but controllable” feels far more reassuring than relying on a pile of external services. Of course, this is not for everyone. From my perspective, it suits people who meet all three of the following conditions: a certain technical background, a genuine interest in tinkering, and a willingness to spend time in exchange for stability and control.
I don’t consider myself an “expert” even now, but over time, two feelings have become increasingly strong: security and the cleanliness of the server environment. These are also the two main threads of this article. They almost entirely determine whether the services on your server can be used reliably over the long term. They also determine how painful it is when you need to migrate, for example when switching to a cheaper or better VPS provider. I’ve migrated several times myself, and only after doing it enough times did I finally settle down.
The Early, Rough Phase
In the beginning, my approach was very straightforward. I installed services directly on the host machine, then set up Nginx or Apache (httpd). For example, I used Nginx as a reverse proxy. One advantage of Nginx is that its configuration files can be split up, one per site, with services distinguished by domain names rather than port numbers. Everything looked neat and orderly, and at the time I thought this setup was quite elegant.
However, as the number of services gradually increased, problems began to
surface. Some services required specific dependency versions, and it became very
difficult to install multiple versions of libraries or databases in the same
environment. Others left behind various temporary files. Configuration files
were scattered under /etc, data lived in /srv, and logs ended up in /var.
At first, you could still remember which belonged to which service, but over
time everything blended together.
During this phase, when I wanted to remove a service I no longer needed, I was no longer confident that I could remove it cleanly. This was especially true for services installed via “one-click install scripts”. Installation felt great at the moment, but when you later wanted to get rid of it, you realized you had no idea what that script had modified or how to revert it. You couldn’t be sure whether configuration files or background processes were still lingering, and you didn’t know whether deleting the wrong thing might affect other services. That uncertainty itself is a risk, and for someone with a bit of a cleanliness obsession, it’s deeply uncomfortable.
The Containerization Phase
Eventually, I migrated almost all services into Docker. Over time, I’ve become increasingly inclined to containerize everything that can reasonably be containerized. For services that don’t provide a standard containerized deployment but that I still want to use, I simply package them myself into containers and build my own containerized solution.
What truly changed the experience wasn’t Docker itself, but Docker Compose. Containers, by nature, should not be treated as long-term maintenance objects. They should be disposable and rebuildable at any time. Early on, I thought Docker Compose configuration files were cumbersome, and I preferred creating containers directly via commands and letting them run indefinitely. Later, when updates were needed, or when a service depended on multiple underlying containers, the problems became obvious.
If you write down the deployment of a service in a configuration file from the start, then every deployment becomes fully reproducible. You no longer need to manually configure bridge networks, hostnames, or restart policies each time. This saves a huge amount of time and energy and significantly reduces the chance of configuration errors.
At this point, I’ve come to a clear conclusion: there are only three things that truly need long-term maintenance:
- Service configuration (including versions, networks, storage, etc.)
- Data (primarily volumes and other mapped directories)
- Deployment configuration (Docker Compose YAML files)
Whether you reinstall the OS or move to a new machine, as long as these three remain intact, the services can be perfectly replicated elsewhere. Meanwhile, the host system itself finally becomes “clean”. You no longer have directories filled with things you installed years ago and have since forgotten. At this stage, I created a dedicated Git repository to collect and manage all the Docker Compose configurations for my services. After a year of using this approach, I’m convinced it holds up well across a wide range of scenarios.
One Service, One Database
This is a point worth emphasizing. For a long time, all my services shared a single database instance. Logically, this seemed reasonable: fewer resources, simpler structure, centralized management. But in long-term maintenance, it turns out to be a very unfriendly design.
Every additional service means another set of users, permissions, and passwords. Migrating a single service requires carefully extracting its database. A database version upgrade puts all services at risk simultaneously. For anyone not deeply familiar with a given database, managing users and permissions via the command line or database tools is painful. You must carefully distinguish and securely store credentials for each service.
Eventually, I did the opposite: each service gets its own database instance. In practice, that means each Docker Compose file includes its own database service. Over time, I realized that for personal self-hosting, this is not nearly as wasteful as it sounds. A database container typically consumes only a few hundred megabytes of memory. On a 4-core, 8 GB VPS, or even 2-core, 4 GB, or a home server, this is entirely acceptable.
The benefits are very tangible. Services are completely isolated, security is significantly improved, and there’s no longer endless friction around permissions or compatibility. You can simply pack up all the data and configuration and move it elsewhere. Each database contains only the data for its corresponding service.
The Principle of Minimal Exposure
Early on, I exposed almost all services directly to the public internet, protected by a reverse proxy and login mechanisms:
- One Nginx Proxy Manager
- Public domains for all services
- Application-level username/password protection
At the time, I thought, “Every service has a username and password, so it should be fine.” Eventually, I realized this was self-deception. As long as one single service has a brute-force vulnerability, authentication bypass, or RCE (remote code execution), an attacker may gain container access or even host-level access.
In my own case, I once had a non-containerized GitLab deployment. GitLab had a severe RCE vulnerability that I didn’t learn about in time. One day, I discovered the server had been injected with malware, and I only found out because the cloud provider notified me. If that server had contained personal photos, videos, or backups, the consequences would have been catastrophic.
Different services have vastly different levels of security maturity. Their tech stacks vary widely, some projects are still young, and security is not always a priority. I can’t realistically track every security advisory. Once a service or one of its dependencies, for example something in the modern JavaScript ecosystem, suddenly breaks, it can explode in a very short time window. If containerization and isolation are weak, the attacker may gain access not just to that service, but potentially the entire machine, followed by hidden malware or crypto-mining scripts.
This led me to a core principle: if a service does not need to be publicly accessible, it should not be exposed to the public internet. Blogs and static sites have limited damage potential. But notes, password managers, admin panels, and similar services can be disastrous if compromised. So it’s crucial to reassess what you’re exposing. In my experience, static sites or services without databases are the safest. For everything else, I aim to keep them private. Git repositories are either not exposed at all or only via read-only static views. Dynamic services, admin interfaces, notes, and password managers are never directly exposed to the internet.
Deploying a Private VPN
Eventually, I realized the key to solving this problem was a VPN. I deployed a private VPN on the server, for example using WireGuard, and placed all private services into a network accessible only via the VPN. Only after connecting to the VPN and obtaining a virtual internal IP can these services be accessed. The overall idea is:
- The VPN provides a virtual private subnet, such as
10.8.x.x - Docker internal services use another private subnet, such as
172.x.x.x - The two subnets are connected via routing
- Public internet access to these services is impossible
This fundamentally changes the security model. I no longer need to design a separate defense scheme for every service, because they are not part of the attack surface at all. Combined with an internal reverse proxy and DNS, the user experience is actually better than public exposure: clean domains, stable services, and a strong sense of security.
Internal Reverse Proxy and DNS
To further improve the experience, I run an internal Nginx container within an internal Docker network, completely isolated from the externally facing Nginx instance. This internal Nginx binds to a private IP and only serves traffic coming from the VPN.
flowchart LR
%% Clients
PUB["Public Internet User"] -->|"HTTPS"| PNGX["Public Nginx (*.example.com)"]
PNGX --> PBLOG["Public Service (e.g., blog/static)"]
%% VPN access
DEV["Your Device\n(WireGuard Client)"] -->|"VPN Tunnel"| WG["WireGuard Server (10.8.0.0/24)"]
%% Internal name + reverse proxy (VPN-only)
WG -->|"DNS"| DNS["Internal DNS (172.24.0.4)"]
WG -->|"HTTPS"| INGX["Internal Nginx (172.24.0.2)"]
%% Private services
subgraph DOCKER["Docker Private Networks"]
INGX --> VAULT["Vaultwarden"]
INGX --> NOTES["Notes / Joplin"]
INGX --> GIT["Git Service"]
end
On top of that, I deploy a mandatory internal DNS service for all VPN-connected
traffic. Only this DNS server is allowed. Mature solutions like AdGuard Home
work well here and also provide ad blocking and protection against DNS
hijacking. This DNS service resolves all domains under something like
*.vpn.example.com, for example:
git.vpn.example.comnotes.vpn.example.comvault.vpn.example.com
All of these resolve to the internal Nginx IP. When the VPN is connected, accessing services via these domains in the browser works seamlessly, with all traffic staying inside the private network. Ideally, this domain is owned by you, and externally you configure it to return NXDOMAIN or a safe IP, ensuring that accidental non-VPN access doesn’t leak traffic elsewhere.
Isolating Database Containers
Databases should not share Docker networks with unrelated services. I therefore isolate each service and its database into a dedicated internal bridge network that does not connect to the outside world. Even if a service is compromised, it can only access its own database, not all databases on the server.
For example:
services:
db:
image: postgres:16-alpine
...
networks:
- iso
restart: unless-stopped
joplin:
image: git.stdv.de/saturneric/joplin:latest
...
depends_on:
- db
networks:
- iso
- vpn
restart: unless-stopped
networks:
iso:
internal: true
vpn:
external: true
Here, the database communicates only with its corresponding service on an internal network. Other services cannot reach it. This doesn’t guarantee absolute security, but reducing lateral movement is a critical layer of defense.
This setup also avoids hostname or database name conflicts, which are common when multiple databases share the same network.
Firewall Strategy
If you’re using a VPS, my recommendation is to prioritize the cloud provider’s firewall and minimize host-level firewall rules. Strictly limit inbound ports at the provider level and route services through domains and reverse proxies.
There are several reasons for this. Double firewalls easily become confusing. Docker port mappings can sometimes bypass local firewall rules, leading to accidental exposure. Cloud firewalls can also absorb large-scale scanning traffic, shifting that cost away from your server.
Typically, I only allow the following inbound ports at the cloud firewall level:
- 80 / 443
- VPN port
- SSH
- Any other port you genuinely need
Even if a service listens on the host, if the cloud firewall blocks it, it remains invisible from the outside. Access is only possible via the VPN.
SSH Security
There are many excellent articles on this topic, so I’ll just mention a few basic but crucial practices that I consistently follow:
- Disable SSH login for the root user. Use a normal user with sudo instead.
- Avoid easily guessable SSH usernames like “admin”.
- Use long, random passwords for users, generated by a password manager, and rely primarily on key-based authentication.
- Optionally, disable password login entirely and allow only key-based access. Be careful to back up keys properly.
Many people suggest changing the SSH port. For personal setups, my view is:
- The security benefit is limited
- It increases configuration complexity
- Ultimately, it’s a matter of personal preference
Afterword
I’m now using a dedicated server rather than a VPS, essentially renting a physical machine in a data center. I run dozens of containers, all of which I actively use. In a way, this shows that self-hosting has become a core piece of my daily technical infrastructure, and that the higher monthly cost is justified.
That said, there are environments where routing all traffic through a private VPN is not feasible, for example where server uplink bandwidth is expensive or traffic is metered. In such cases, my suggestion is to perform traffic splitting at home, using a software router, so that self-hosting traffic is routed separately while normal internet traffic uses the home connection.
Finally, if your server has enough uplink bandwidth to support a private VPN, you may still not want to browse the web using your VPS IP, as that directly exposes your identity. A better approach is to route traffic through another VPN provider for outbound internet access. I have a mature, long-term solution for this setup, which you can find in my blog post: Building a Personal Chained VPN Network: Efficient Privacy Protection, Unlimited Device Management, and Remote Access