Renting a 5-euro-per-month VPS and running your own services on it is one of the most educational skills in the trade: you learn networking, Linux, security and operations simultaneously. This guide walks the full path —from freshly minted server to published application with HTTPS— using exactly the pieces you would use in production.
Step 0: pick a provider and create the machine
Hetzner, OVH, DigitalOcean, Vultr... any serious provider works. What matters when creating the instance:
- Ubuntu LTS or Debian: maximum documentation, long update cycles.
- Region closest to your users: latency matters more than price.
- SSH key from the start: if the panel allows it, inject your public key during creation and avoid passwords from second zero.
If you don't have a key pair yet, generate an Ed25519 one (the full process is covered in the SSH keys guide):
ssh-keygen -t ed25519 -C "miguel@laptop"
Step 1: the critical first minutes
A newborn VPS receives automated scans within minutes. Before installing anything, close the front door:
ssh root@YOUR_IP
adduser miguel
usermod -aG sudo miguel
Now edit SSH configuration (/etc/ssh/sshd_config) with three changes:
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
Restart the service (sudo systemctl restart ssh) without closing your current session and open a second terminal to verify key-based login works before losing your first connection. This order saves you from locking yourself out.
The firewall comes next, with ufw:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Everything else stays blocked: only SSH, HTTP and HTTPS answer from outside.
Step 2: install Docker and Compose
Docker turns every service into something reproducible and isolated. Official installation in two commands:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker miguel
(Log out and back in for the docker group to apply.)
From here on, each application is a directory with its docker-compose.yml. A typical app + database stack:
services:
app:
build: .
restart: unless-stopped
environment:
- DATABASE_URL=postgres://app:cambiar@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=cambiar
- POSTGRES_DB=app
restart: unless-stopped
volumes:
pgdata:
Notice what's NOT there: port publishing. The app needs no ports: because Nginx will talk to it through Docker's internal network. Less exposed surface, zero risk of port collisions between services. The full pattern is developed in the Docker Compose guide.
To build your application image with layer caching and multistage, the step-by-step Dockerfile covers Node, Next.js, Python, Go and Rust.
Step 3: Nginx as the front door
With the app running inside Docker, Nginx on the host becomes the single entry point: terminates TLS, compresses, applies security headers and routes domains.
server {
listen 80;
server_name mydomain.es;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name mydomain.es;
ssl_certificate /etc/letsencrypt/live/mydomain.es/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mydomain.es/privkey.pem;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Wait —if we said the app publishes no ports, how does 127.0.0.1:3000 reach it? Two valid options: publish the port on loopback only ("127.0.0.1:3000:3000" in compose) or join Nginx to the project's Docker network and target the service name. Both work; the second is cleaner, the first simpler. All three complete configuration patterns (static, SPA, proxy) are in the Nginx guide.
Certificates, free and automatic:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d mydomain.es
Certbot rewrites your config adding the SSL block and schedules its own renewal.
Step 4: painless deployments
With the base in place, updating your application should be one command:
git pull && docker compose up -d --build
When the project grows, that command migrates to GitHub Actions: push to main → CI runs tests → automatic deploy. The copy-ready GitHub Actions workflow does exactly that.
Minimum viable maintenance
Three routines keep a small server healthy:
- Updates:
sudo apt update && sudo apt upgradeweekly (enableunattended-upgradesfor automatic security patches). Docker images update by re-pulling tags. - Backups: the
pgdatavolume is not a backup. A nightly cron withdocker compose exec db pg_dump app > backup.sqlsynced off-server is. - Basic watching:
docker compose logs -f, disk space (df -h) and failed SSH attempts (journalctl -u ssh) tell you everything needed on small servers. Fail2ban optionally automates brute-force banning.
Rookie mistakes (we all make them)
Opening the app's port 3000 to the world instead of proxying it — duplicating attack surface. Leaving PasswordAuthentication yes after creating users. Not testing the new SSH session before closing the old one. Forgetting docker compose down -v deletes volumes —and the database with them—. Each takes a minute to fix if caught early; none is fun at 3 AM.
Generate the pieces before touching the server
Prepare your keys with the SSH Key Generator, validate resulting TLS state with the SSL Checker, and assemble your stack's compose file with the Docker Compose Generator.
FAQ
How much VPS do I need to start? For 2-3 light services, 2 GB RAM and 1 vCPU are plenty. Docker allows vertical growth by changing plans without rebuilding anything.
Dynamic VPS IP? VPSes come with static IPs; point your domain with an A record and done. Only home services behind dynamic IPs need DDNS.
Kubernetes yet? No. Compose scales further than most expect, and Kubernetes solves problems (multi-node, autoscaling, complex rollouts) a first server doesn't even have. It will come if ever needed.
Start by generating your key pair with the SSH Key Generator, right in your browser.