Self-Hosting for Developers: A Practical Guide to Virtual Private Server (VPS) Hosting with Docker & Monitoring
Introduction
Ever wondered how to host your own blog, monitor your applications, or deploy tools without relying on third-party services? Setting up a Virtual Private Server (VPS) might be the key.
If you're a software developer like me, you've probably imagined it too — owning your own domain, deploying side projects or tools, hosting your blog, or running monitoring dashboards — all from your own private server. It's more than just hosting; it’s about understanding the full lifecycle of software: from writing clean code, packaging it in containers, securing the infrastructure, setting up CI/CD, to watching the logs and metrics in production. It’s about building real-world confidence.
In this article, I’ll walk you through setting up your own Virtual Private Server (VPS) to host Dockerized applications — from initial setup and hardening the server against threats, to running production-ready apps with HTTPS and observability.
Whether you're launching your first side project or just exploring self-hosting, this guide is for developers who want to take control of their deployment story — hands-on, down to the metal.
Let’s build something real — and make it yours.
Choosing a VPS Provider
Before you start deploying apps, the first step is choosing where your server will live — your VPS provider.
This might seem like a small decision, but it sets the foundation for everything that follows. That said, don’t overthink it too much in the beginning — start small, learn by doing, and evolve over time.
Most VPS providers give you a basic Linux machine with root access and some bandwidth. That's all you need to begin self-hosting Dockerized apps. But as you grow, you'll start to notice the trade-offs between price, performance, support, and security features.
Here are a few things to keep in mind when picking your first VPS:
- Start Small — You Can Always Scale: Don't overcommit to a high-end plan right away. Start with a basic instance ( 1-2 vCPUs, 2-4GB RAM) and see how it goes. You can scale up later as your apps grow or your needs change. Focus on learning, not perfection.
- Look for Docker Support: Most providers support Docker out of the box, but double-check that you can install Docker and Docker Compose easily. This will save you headaches later.
- Understand What You're (Not) Getting: Unlike cloud providers like AWS or GCP, many VPS providers don’t offer managed firewalls, IAM, auto backups, or DDoS protection by default. You are responsible for security hardening, backups, monitoring, and software updates. This gives you freedom and full control — but also more responsibility.
- Check for Data Center Locations: If you have a global audience, consider where the provider's data centers are located. Closer proximity to your users means lower latency and better performance.
- Read Reviews and Ask for Recommendations: Look for community feedback on forums like Reddit, Hacker News, or specialized hosting review sites. Ask fellow developers about their experiences with different providers. My personal recommendation is to start with a well-known provider like DigitalOcean, VPS Server, IONOS, Linode, fly.io or Vultr. They have good documentation, community support, and are beginner-friendly.
My Personal Recommendation
Don't spend too much time comparing every provider. Just pick one, start building, and you’ll learn much more by doing than reading endless comparison charts.
My VPS Setup Goals
Before diving into the setup, let me share what I wanted from my VPS — not just a server, but a personal, developer-friendly playground where I could run and observe real-world applications just like in production.
Below is the architecture I wanted to achieve:

This architecture is designed to be:
- 🧩 Simple by Design: no Kubernetes, no multi-cloud maze. Just a single server I control end to end.
- 👨💻 Developer-Centric: Built to run real applications — mainly Spring Boot REST APIs, packaged as Docker containers, not to manage infrastructure.
- 🔐 Secure from Day One: Hardened with firewall rules, SSH best practices, automatic security updates, and HTTPS via Let’s Encrypt.
- 📊 Observability-Ready: Set up monitoring, logging, and health checks to understand app performance.
- ⚙️ Automated Where It Matters: Use CI/CD to deploy code changes seamlessly, with auto-restarts of containers on updates.
- 📦 Scalable by Nature: Start small, but easily scale up as needed without major rework.
Here’s a quick breakdown of how my setup comes together:
- Ubuntu OS: A reliable Linux-based server I fully control.
- GitHub: GitHub as Source of Truth for version control and deployment management.
- SonarQube Cloud: SonarQube for secure coding, code quality and security checks.
- GitHub Actions: For CI/CD, Automate builds and deployments directly from GitHub.
- Docker: to Dockerize Applications, All apps run in containers for consistency and isolation.
- Watchtower: Automatically restarts containers when new images are pushed to Docker Hub.
- NGINX: Nginx Reverse Proxy, Handles incoming traffic, routes requests, and secures with Let's Encrypt HTTPS.
- Grafana and Prometheus: Monitor and Track app performance and server health.
- Prometheus Alert Manager: Performs health checks and ensure apps are running smoothly with notifications for issues.
- MongoDB Atlas: Use externalized, managed MongoDB for structured data storage.
- GitHub Pages: Host static frontend apps (SPAs) on GitHub Pages.
- Render: Offload rare background tasks to Render's pay-as-you-go async services.
- Scheduled Backups: Regularly back up data and configurations to avoid data loss with help of shell script and cron job scheduler.
Note
MongoDB Atlas, Render and GitHub Pages are optional components. You can replace them with your preferred alternatives or self-hosted solutions. The core principles of the setup remain the same.
Initial Server Setup
Once you've chosen your VPS provider and launched your server, it's time to lay the groundwork.
This section walks through essential steps to secure and prepare your server for deploying Dockerized applications. I'm using Ubuntu as my base OS, but you can adapt these steps to any Linux distribution you prefer.
Create New User
Connect to your VPS and create a new user. This is a good security practice to avoid using the root user for everyday tasks.
Use SSH to connect to your server:
Create a new user e.g. devops and add it to the sudo group:
Set Up SSH Key Authentication
Generate an SSH key pair on your local machine (if you haven't already):
now connect to your server using the new user:
Update and Upgrade Packages
Before doing anything else, ensure your server is up to date. This is crucial for security and stability.
Set the timezone and hostname to your preference(Optional):
Automatically update the package index and upgrade installed packages:
Automatically reboot the server if necessary after updates /etc/apt/apt.conf.d/50unattended-upgrades:
Expanded Security Maintenance
To keep your server secure, consider enabling Ubuntu Pro for extended security maintenance (ESM) on Ubuntu LTS releases.
# Enable Ubuntu Pro
sudo pro attach <your-token>
# Check status
sudo pro status
# Enable ESM for apps and infrastructure
sudo pro enable esm-apps
sudo pro enable esm-infra
sudo pro enable livepatch
Install Docker & Docker Compose
Docker is essential for running containerized applications. Install Docker and Docker Compose on your server:
sudo apt install docker.io docker-compose -y
sudo usermod -aG docker $USER
sudo systemctl enable docker
sudo systemctl start docker
Security Hardening
Harden SSH Access
Passwords can be brute-forced. SSH keys are much harder to crack.
To improve security, we'll change the default SSH port, disable root login, and enforce key-based authentication.
Edit the SSH configuration file:
Change the following settings:
# Change the default SSH port for reducing automated attack noise(optional)
Port 2222
# Disable root login
PermitRootLogin no
# Disable password authentication
PasswordAuthentication no
# Enable public key authentication
PubkeyAuthentication yes
Test your SSH configuration,
Restart the SSH service to apply changes:
Install Fail2ban
Fail2ban protects SSH and other services by banning suspicious IPs after multiple failed login attempts to prevent brute-force attacks.
Configure Fail2ban
Edit the Fail2ban configuration file:
Add or modify the following lines:
[sshd]
enabled = true
port = 2222 # your custom SSH port
maxretry = 5
bantime = 3600 # ban for 1 hour
[nginx-http-auth]
enabled = true
maxretry = 10
findtime = 1m
bantime = 10m
backend = auto
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
[nginx-limit-req]
enabled = true
filter = nginx-limit-req
backend = auto
logpath = /var/log/nginx/error.log
maxretry = 10
findtime = 1m
bantime = 10m
[nginx-botsearch]
enabled = true
filter = nginx-botsearch
maxretry = 10
findtime = 1m
bantime = 10m
backend = auto
logpath = /var/log/nginx/access.log
[nginx-bad-request]
enabled = true
filter = nginx-bad-request
backend = auto
logpath = /var/log/nginx/error.log
maxretry = 10
findtime = 1m
bantime = 10m
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
bantime = 604800 # 1 week in seconds
findtime = 86400 # 1 day
maxretry = 5
backend = auto
Note
If you are running Nginx with Docker and Systemd, you may ignore logpath settings in the above configuration.
By default, Fail2ban will look for logs in nginx's journal logs, which are accessible via journalctl -u nginx.service.
Restart Fail2ban to apply changes:
Check the status of Fail2ban to ensure it's running correctly:
To unban an IP address, use the following command:
Optionally you can send notifications to your email or Telegram when an IP is banned by Fail2ban.
To set up telegram notifications, create a file /etc/fail2ban/action.d/telegram.conf with the following content:
[Definition]
actionstart =
actionstop =
actioncheck =
actionban = curl -s -X POST https://api.telegram.org/bot<TOKEN>/sendMessage -d chat_id=<CHAT_ID> -d text="🚨 Fail2Ban: <name> banned IP <ip> on <hostname>"
actionunban = curl -s -X POST https://api.telegram.org/bot<TOKEN>/sendMessage -d chat_id=<CHAT_ID> -d text="✅ Fail2Ban: <name> unbanned IP <ip> on <hostname>"
[Init]
name = default
Replace <TOKEN> with your Telegram bot token and <CHAT_ID> with the chat ID where you want to receive notifications.
now, add the action to your jail configuration file /etc/fail2ban/jail.local:
Set Up Firewall (UFW)
Install and enable UFW (Uncomplicated Firewall) to restrict access to your server:
Deny all incoming connections and allow only outgoing by default:
Implement rate limiting for SSH connections to prevent brute-force attacks:
Finally, enable UFW:
Check the status of UFW to ensure it's active and rules are applied:
To delete a specific rule, use the following command:
Optionally, enable logging to monitor firewall activity /var/log/ufw.log:
Once you enable firewall, finally perform a security audit to ensure everything is set up correctly:
# Check all available user accounts
getent passwd | cut -d: -f1
# Check for users with UID >= 1000 (non-system users)
awk -F: '$3 >= 1000 && $3 < 65534 { print $1 }' /etc/passwd
# Check for users with shell access
grep -vE '(/nologin|/false)' /etc/passwd | awk -F: '{print $1, $7}'
# Check for users with sudo privileges
getent group sudo | cut -d: -f4
Use Security Scanner
To further enhance security, consider using a security scanner like Lynis to audit your server:
Review the report and follow the recommendations.
Warning
Security is never done. It's a habit — not a checklist. These practices will protect you against most common attacks, but it's always worth staying updated on best practices. Regularly review your server's security posture, update packages, and monitor logs for suspicious activity.
NGINX Setup
NGINX is a powerful web server and reverse proxy that can handle incoming traffic, route requests to your Docker containers, and secure your applications with HTTPS.
Install Nginx on your server:
Setup Reverse Proxy
Let's set up Nginx to act as a reverse proxy for your Dockerized applications. This allows you to access your apps through a domain name or IP address without exposing the Docker container ports directly.
As an example, let's assume you have a Dockerized application running on port 8080 and want to access with domain name
https://example.com.
You can create a new Nginx configuration file for your application:
Add the following configuration to the file:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:8080/;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Create a symbolic link to enable the configuration:
Test the Nginx configuration for syntax errors:
Restart Nginx to apply the changes:
Now, Nginx will listen on port 80 and forward requests to your Docker container running on port 8080.
Note
If you want to access your application using a domain name, make sure to point your domain's DNS records to your VPS IP
address. You can do this by creating an A record for your domain that points to your server's IP address.
you can use https://dnschecker.org/ to check if your DNS records are propagated.
Set Up HTTPS
To secure your application with HTTPS, use Let's Encrypt to obtain a free SSL certificate.
This command will automatically configure Nginx to use the SSL certificate and set up automatic renewal.
After setting up Nginx and obtaining the SSL certificate, verify that everything is working correctly by visiting
your domain https://example.com in a web browser. You should see your application served over HTTPS.
Set Up Automatic SSL Renewal
To ensure your SSL certificate is renewed automatically, add a cron job:
Add the following line to run the renewal command daily:
Implement rate limiting
To protect your applications from abuse, you can implement rate limiting in Nginx. This helps prevent DDoS attacks and ensures fair usage of resources.
You can set up a rate limit in your Nginx configuration file,
Create or edit your Nginx configuration file (e.g., /etc/nginx/sites-available/example.com):
http {
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=5r/s;
server {
location / {
limit_req zone=req_limit_per_ip burst=10 nodelay;
proxy_pass http://localhost:8080/;
}
}
}
This configuration limits each IP address to 5 requests per second, with a burst capacity of 10 requests. If the limit
is exceeded, Nginx will return a 503 Service Unavailable response.
Test the rate limiting configuration:
If the rate limit is working correctly, you should see 200 OK responses for the approximately first 15 requests,
followed by 503 Service Unavailable for the subsequent requests that exceed the limit.
Application Deployment
Now that your server is set up and secured, it's time to deploy your Dockerized applications.
Deploying Dockerized Applications
My typical development workflow is as follows:
🧑💻Writing Code -> 🔀 Push to GitHub → ⚙️ GitHub Actions (CI/CD) → 📦 Build Docker Image → 🐳 Publish to Docker Hub
This workflow is defined in a journey-api/.github/workflows/release-workflow.yml file in my repository.
Let's deploy the dockerized application using Docker Compose.
Create docker-compose.yml file in your VPS server:
Configure the docker-compose.yml file to define your application services, networks, and volumes. Here’s a basic
example for a Spring Boot application:
version: '3.8'
services:
app:
image: your-docker-image:latest
container_name: your-app-container
restart: always
ports:
- "8080:8080" # Map host port to container port
environment:
- SPRING_PROFILES_ACTIVE=prod # Set active profile
env_file:
- ./default.env
volumes:
- ./data:/app/data # Mount a volume for persistent data
networks:
- app-network
networks:
app-network:
driver: bridge
Tip
To manage all your applications containers and services, you can create a custom network called app-network
and connect all your application containers to this network. This allows them to communicate with each other
without exposing ports to the host machine, enhancing security and isolation.
# To create the network, run the following command:
docker network create custom-apps--network
# To list all available networks
docker network ls
# To connect a container to the network, use the --network flag when running the container:
docker run -d --name your-app-container --network custom-apps-network your-docker-image:latest
# To inspect the network and see connected containers, use:
docker network inspect custom-apps-network
# To remove the network, use:
docker network rm custom-apps-network
This docker-compose.yml file defines a service named app that uses a Docker image, maps ports, sets environment
variables, mounts a volume for persistent data, and connects to a custom network.
To start your application using Docker Compose, run the following command in the directory where your
docker-compose.yml file is located:
This command will start your application in detached mode, allowing it to run in the background. You can check the status of your application by running:
This will show you the status of your containers, including whether they are running or stopped.
Automating Deployment
Watchtower monitors your running containers and automatically pulls new images from Docker Hub, restarts the containers, and cleans up old versions.
🔄 Pull latest Image → 🚀 Deploy to VPS → ♻️ Auto-Restart container → 🗑️ Clean up old images
To set up Watchtower, run the following command:
docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower \
--interval 300 \
--cleanup
This command runs Watchtower in detached mode, allowing it to monitor your containers every 5 minutes (300 seconds).
Monitoring & Observability
In my setup, I've kept things simple and focused on what truly matters: metrics, dashboards, and alerts — using tools I can run in Docker with minimal overhead.
Below are the tools I use for monitoring and observability:
| Tool | Purpose |
|---|---|
| Prometheus | Collects and stores metrics from applications and services, including Docker containers |
| Node Exporter | Collects system metrics (CPU, memory, disk) from the host machine |
| Grafana | Visualizes metrics in real-time dashboards |
| Spring Boot Actuator | Exposes health & metrics endpoints for domain-specific counters and timers (via Micrometer) |
| Alertmanager | Manages alerts based on Prometheus metrics, sends notifications via email/Telegram |
| cAdvisor | Collects resource usage and performance metrics from running containers |
My spring boot application exposes metrics and health endpoints at http://localhost:8080/actuator/prometheus using
Spring Boot Actuator and
Micrometer.
Prometheus scrapes that endpoint every 15 seconds and stores the metrics. Grafana queries Prometheus to display charts, usage graphs, and alerts.
You can run everything using Docker, below is a sample docker-compose.yml file:
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
mem_limit: 128m
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
restart: unless-stopped
networks:
- monitoring-network
extra_hosts:
- "host.docker.internal:host-gateway"
grafana:
image: grafana/grafana:latest
container_name: grafana
mem_limit: 128m
ports:
- "3000:3000"
volumes:
- grafana-storage:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_USER= your_admin_username
- GF_SECURITY_ADMIN_PASSWORD=your_admin_password
networks:
- monitoring-network
alertmanager:
image: quay.io/prometheus/alertmanager
container_name: alertmanager
ports:
- "127.0.0.1:9093:9093"
restart: unless-stopped
networks:
- custom-apps-network
volumes:
- ./alertmanager:/etc/alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter
network_mode: host
pid: host
command:
- '--path.rootfs=/host'
volumes:
- '/:/host:ro,rslave'
restart: unless-stopped
cap_add:
- SYS_TIME
- SYS_ADMIN
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: cadvisor
volumes:
- /:/rootfs:ro
- /var/run:/var/run:rw
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
restart: unless-stopped
ports:
- "9094:8080"
networks:
- custom-apps-network
volumes:
grafana-storage:
networks:
monitoring-network:
external: true
This docker-compose.yml file sets up Prometheus, Grafana, and Node Exporter in a single network called
monitoring-network. Make sure to create the prometheus.yml configuration file for Prometheus and the Grafana
provisioning files for dashboards and data sources based on your application configuration.
below is a sample prometheus/prometheus.yml file:
global:
scrape_interval: 15s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: [ "alertmanager:9093" ]
rule_files:
- "alert.rules.yml"
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: [ 'localhost:9090' ]
- job_name: 'node-exporter'
static_configs:
- targets: [ 'host.docker.internal:9100' ]
- job_name: 'cadvisor'
static_configs:
- targets: [ 'cadvisor:8080' ]
- job_name: 'your-app-service'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: [ 'your-app-container:8081' ]
Below is a sample Grafana provisioning file grafana/provisioning/dashboards/dashboard.yml for dashboards:
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
Note
Do not forget to enable firewall rules for Prometheus and Grafana ports in your UFW configuration:
sudo ufw allow from 127.0.0.1 to any port 3000 # grafana
sudo ufw allow from 127.0.0.1 to any port 9090 # prometheus
sudo ufw allow from 127.0.0.1 to any port 9094 #
sudo ufw allow from 127.0.0.1 to any port 9093 # alertmanager
sudo ufw allow from 127.0.0.1 to any port 9092 #
sudo ufw allow from 127.0.0.1 to any port 9091 #
sudo ufw allow from 127.0.0.1 to any port 9100 # node-exporter
sudo ufw allow from 127.0.0.1 to any port 8081 # application metrics exporter
I've created a few Grafana dashboards to visualize the metrics from my applications:
Tip
If you want to learn more about setting up observability in Spring Boot applications, check out these articles:
Health Checks and Alerts
To ensure your applications are running smoothly, set up health checks and alerts using Prometheus Alertmanager.
Please refer to the Prometheus Alertmanager documentation for detailed instructions on configuring alerts based on your application metrics.
For inspiration, you can refer to my alert.rules.yml.
Tip
Awesome Prometheus alerts is a great resource for pre-built Prometheus alert rules that you can use as a starting point.
Scheduled Backups
When you're running your own server, one unexpected mistake or system failure can wipe out everything — your code, configs, uploaded files, dashboards, and even SSL certificates.
That's why backups aren't optional — they're essential.
I use a simple Bash script scheduled with cron to run every night. It backs up,
- Docker volumes and docker-compose files
- Nginx configs
- Monitoring files such as Grafana dashboard, Prometheus config, and Alertmanager rules
- Nginx SSL certificates using
certbotto ensure I can restore HTTPS quickly - Server configuration files, including SSH keys, UFW rules
- Application logs to help with debugging and auditing
- Cron jobs and scheduled tasks
- Environment variables and secrets using Docker secrets or
.envfiles
Here's a sample backup script:
#!/bin/bash
BACKUP_DIR="/path/to/backup/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
# Backup Docker volumes
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-v "$BACKUP_DIR":/backup \
-e BACKUP_DIR=/backup \
containrrr/watchtower backup
# Backup Nginx configs
cp -r /etc/nginx "$BACKUP_DIR/nginx"
# Backup Grafana dashboards
cp -r /var/lib/grafana/dashboards "$BACKUP_DIR/grafana"
# Backup Prometheus config
cp -r /etc/prometheus "$BACKUP_DIR/prometheus"
# Backup SSL certificates
cp -r /etc/letsencrypt "$BACKUP_DIR/letsencrypt"
# Backup server configuration files
cp -r /etc/ssh "$BACKUP_DIR/ssh"
cp -r /etc/ufw "$BACKUP_DIR/ufw"
# Backup application logs
cp -r /var/log/myapp "$BACKUP_DIR/logs"
# Backup cron jobs
crontab -l > "$BACKUP_DIR/cron_jobs.txt"
# Backup environment variables
cp -r /etc/environment "$BACKUP_DIR/environment"
# Remote MongoDB Atlas backup
mongodump --uri="mongodb+srv://user:[email protected]/mydb" \
--archive="$BACKUP_DIR/mongo_atlas.gz" --gzip
# Compress the backup directory
tar -czf "$BACKUP_DIR.tar.gz" -C "$BACKUP_DIR" .
# Remove the uncompressed backup directory
rm -rf "$BACKUP_DIR"
# Cleanup old backups (older than 7 days)
find "/path/to/backup/" -type f -name "*.tar.gz" -mtime +7 -delete \;
This script creates a timestamped backup directory, copies essential files and directories, compresses the backup, and cleans up old backups older than 7 days.
Regular HouseKeeping Tasks
To keep your server running smoothly, it's important to perform regular housekeeping tasks:
- Regularly remove stopped containers, unused volumes, and dangling images
docker system prune -af --volumes. - Monitor disk space usage with
df -hand clean up old logs or unused files. docker restart watchtowerto ensure Watchtower is running smoothly.- Verify HTTPS & Certificates with
certbot renew --dry-runto ensure automatic renewal is working. - Check application logs for errors or warnings using
docker logs your-app-container. - Review Prometheus and Grafana dashboards for any anomalies or performance issues.
Conclusion
Running your own VPS isn't just a tech project — it's a developer's dream come true. 💙
You write the code, deploy it yourself, monitor it, secure it — and learn more than any tutorial can teach. It's simple, powerful, and fully yours.
This journey isn’t about chasing perfection.
Start small. Stay curious. Build something you can be proud of.
Good luck on your self-hosting journey!
Did this post help you? Share on: X (Twitter) Facebook LinkedIn reddit WhatsApp Hacker News