Skip to content
Client Panel

Setting Up a Production-Ready VPS from Scratch: Rocky Linux 10 & Ubuntu 26.04

Tomochi stacking Traefik, TLS, firewall and hardening layers onto a bare VPS

Setting Up a Production-Ready VPS from Scratch: Rocky Linux 10 & Ubuntu 26.04

Section titled “Setting Up a Production-Ready VPS from Scratch: Rocky Linux 10 & Ubuntu 26.04”

Adapted from Dreams of Code, a dual-OS guide covering both RHEL-family and Debian-family setups.


Deploying to the cloud has never been easier. Platform-as-a-Service (PaaS) options like Railway, Fly.io, and Render make going live a breeze. But PaaS isn’t perfect for every use case: long-running tasks, heavy data transfer, and predictable billing often push teams toward a VPS (Virtual Private Server).

The perceived difficulty of hardening and configuring a raw VPS scares people off. But is it actually hard? We set out to prove it isn’t. Here’s a production-ready stack on both Rocky Linux 10 and Ubuntu 26.04 LTS.

  • DNS pointing to the server
  • Application deployed and running (Docker)
  • HTTPS/TLS with automatic cert provisioning & renewal (Let’s Encrypt)
  • Hardened SSH: no root login, no password auth
  • Firewall blocking unnecessary ports
  • High availability: multiple app instances
  • Load balancing via reverse proxy
  • Automated rolling deployments
  • Uptime monitoring with alerts

Constraints: No Kubernetes. No Coolify. No Terraform. Simple tooling, minimal domain expertise.


Pick a provider (Hetzner, Hostinger, DigitalOcean, Vultr). We used a 2 vCPU / 8 GB RAM instance.

💡 Hosting with a side of nasi goreng? 🇮🇩 8labs offers VirtualLabs and ElasticLabs : VPS and scalable infrastructure, straight out of Indonesia. 🤙

During setup via your provider’s panel:

  1. Select Rocky Linux 10 or Ubuntu 26.04 LTS
  2. Set a strong root password
  3. Add your SSH public key
  4. Disable any “malware scanner” or monitoring agent if you don’t need it

Once deployed, test SSH:

Terminal window
ssh root@<your-server-ip>

Working as root is a bad habit. Create a regular user with sudo/wheel privileges.

Terminal window
# Create user
useradd -m -s /bin/bash deployer
passwd deployer
# Add to wheel group (Rocky's sudo equivalent)
usermod -aG wheel deployer
# Test
su - deployer
sudo echo "sudo works"

Tip: Install tmux on the VPS. If your SSH drops, reattach with tmux attach, no lost progress.

Terminal window
sudo dnf install -y tmux # Rocky
sudo apt install -y tmux # Ubuntu

Point your domain to the VPS:

  1. Clear any existing A/AAAA/CNAME records at your registrar
  2. Add an A record for @ (root domain) pointing to your server’s IPv4
  3. Optionally add a www CNAME pointing to @

Find your server IP:

Terminal window
ip -4 addr show | grep inet
# or
curl -4 ifconfig.me

DNS propagation takes minutes to hours. Move on to security while waiting.


SSH is your front door. Lock it down.

If you don’t already have an SSH key pair, generate one on your local machine:

Terminal window
ssh-keygen -t ed25519 -C "[email protected]"
# Press Enter to accept the default path (~/.ssh/id_ed25519)
# Set a passphrase (recommended) or leave empty

Ed25519 vs RSA: Ed25519 is faster, more compact, and just as secure as RSA 4096. It is supported by OpenSSH 6.5+ (released 2014), so it works on every modern server. Only use RSA 4096 if you need to connect to legacy servers running pre-2014 OpenSSH:

Terminal window
ssh-keygen -t rsa -b 4096 -C "[email protected]"

Then copy the public key to your server:

Terminal window
ssh-copy-id deployer@<server-ip>

If you already have an SSH key, skip the generation step and jump straight to ssh-copy-id.

Test key-based login before proceeding:

Terminal window
ssh deployer@<server-ip>

Typing ssh [email protected] every time gets old. Add an entry to your local ~/.ssh/config:

Host prod-server
HostName 203.0.113.42
User deployer
IdentityFile ~/.ssh/id_ed25519
Host myapp.com
HostName myapp.com
User deployer
IdentityFile ~/.ssh/id_ed25519

Now you can connect with just:

Terminal window
ssh prod-server
ssh myapp.com

Tip: Use short, memorable Host aliases. The HostName can be either an IP address or a domain name. If you’re managing multiple servers, add an entry for each one — your fingers will thank you.

Terminal window
sudo vim /etc/ssh/sshd_config

Set or uncomment these lines:

PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
# Rocky: also check /etc/ssh/sshd_config.d/*.conf for overrides
# Ubuntu: also check /etc/ssh/sshd_config.d/50-cloud-init.conf

Cloud images often ship a drop-in that re-enables password auth. Check and fix:

Terminal window
sudo grep -r "PasswordAuthentication" /etc/ssh/sshd_config.d/
Terminal window
sudo systemctl reload sshd
# This MUST fail:
ssh root@<server-ip>
# Permission denied (publickey) ← good
# This MUST work:
ssh deployer@<server-ip>

⚠️ Do not close your current SSH session until you’ve verified a new session works. Open a second terminal for testing.


Rocky uses firewalld by default (not ufw).

Terminal window
# Check status
sudo systemctl status firewalld
# If not running, install and start:
sudo dnf install -y firewalld
sudo systemctl enable --now firewalld
# Default zones
sudo firewall-cmd --get-default-zone # usually 'public'
# Allow SSH (CRITICAL: do this first)
sudo firewall-cmd --permanent --add-service=ssh
# Reload to apply
sudo firewall-cmd --reload
# Verify
sudo firewall-cmd --list-all

Docker bypass warning: Docker manipulates iptables directly, which can bypass both firewalld and ufw rules for published ports. The solution: don’t publish container ports directly. Use a reverse proxy (Step 7) and only expose ports 80/443 on the host.

Terminal window
# Remove old Docker packages if any
sudo dnf remove -y docker docker-client docker-client-latest docker-common \
docker-latest docker-latest-logrotate docker-logrotate docker-engine
# Add Docker repo
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
# Install Docker
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Start and enable
sudo systemctl enable --now docker
# Add user to docker group
sudo usermod -aG docker deployer
# Verify
docker --version
docker compose version

If dnf config-manager isn’t available:

Terminal window
sudo dnf install -y 'dnf-command(config-manager)'

Log out and back in (or newgrp docker) for the group change to take effect.

We’ll use Docker Compose with a Go guestbook app + PostgreSQL, just like the original.

Create the project directory:

Terminal window
mkdir -p ~/guestbook/db
cd ~/guestbook

Create a secure Postgres password:

Terminal window
echo "your-strong-random-password-here" > db/postgres-password.txt
chmod 600 db/postgres-password.txt
services:
db:
image: postgres:17
restart: always
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
volumes:
- postgres-data:/var/lib/postgresql/data
secrets:
- postgres-password
guestbook:
image: ghcr.io/yourusername/guestbook:prod
restart: always
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgres?sslmode=disable
ports:
- "8080:8080"
depends_on:
- db
secrets:
postgres-password:
file: ./db/postgres-password.txt
volumes:
postgres-data:

Deploy:

Terminal window
export POSTGRES_PASSWORD=$(cat db/postgres-password.txt)
docker compose up -d

Verify:

Terminal window
docker compose ps
curl http://localhost:8080

Don’t expose port 8080 on the host permanently. We’ll remove it after setting up the reverse proxy.


Traefik handles routing, TLS termination, and load balancing, all via Docker labels.

Update compose.yaml:

services:
reverse-proxy:
image: traefik:v3.3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
db:
# ... unchanged
guestbook:
# ... unchanged except:
ports: [] # ← remove the host port mapping
labels:
- "traefik.enable=true"
- "traefik.http.routers.guestbook.rule=Host(`yourdomain.com`)"
# ... secrets and volumes unchanged

Open port 80 on the firewall:

Terminal window
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --reload

Redeploy:

Terminal window
docker compose up -d

Now visit http://yourdomain.com. Traefik routes traffic to the guestbook container.


Step 9: Load Balancing: Run Multiple Instances

Section titled “Step 9: Load Balancing: Run Multiple Instances”

Traefik automatically load-balances across containers with the same service name.

Terminal window
docker compose up -d --scale guestbook=3

To make it permanent, add to compose.yaml:

services:
guestbook:
# ...
deploy:
replicas: 3

Traefik round-robins by default. No extra config needed.


Step 10: HTTPS with Let’s Encrypt (Automatic TLS)

Section titled “Step 10: HTTPS with Let’s Encrypt (Automatic TLS)”

Update Traefik service in compose.yaml:

services:
reverse-proxy:
image: traefik:v3.3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt

Update guestbook labels:

services:
guestbook:
labels:
- "traefik.enable=true"
- "traefik.http.routers.guestbook.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.guestbook.entrypoints=websecure"
- "traefik.http.routers.guestbook.tls.certresolver=letsencrypt"
# HTTP → HTTPS redirect
- "traefik.http.routers.guestbook-http.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.guestbook-http.entrypoints=web"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
- "traefik.http.routers.guestbook-http.middlewares=redirect-to-https"

Open port 443:

Terminal window
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

Redeploy:

Terminal window
docker compose up -d

Traefik automatically obtains and renews Let’s Encrypt certificates. The acme.json file stores them. Keep it safe (600 permissions, handled by Docker volume).


Step 11: Automated Deployments with Watchtower

Section titled “Step 11: Automated Deployments with Watchtower”

Watchtower monitors Docker image registries and updates running containers when new images appear.

Add to compose.yaml:

services:
watchtower:
image: containrrr/watchtower
command:
- "--label-enable" # Only update containers with the label
- "--interval"
- "30" # Check every 30 seconds
- "--rolling-restart" # One container at a time (for multi-replica)
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro

Add the label to guestbook:

services:
guestbook:
labels:
# ... existing Traefik labels
- "com.centurylinklabs.watchtower.enable=true"

Now when you push a new image to ghcr.io/yourusername/guestbook:prod, Watchtower picks it up within 30 seconds and performs a rolling restart, zero downtime.


Free uptime monitoring options:

  • Uptime Robot: 50 monitors, 5-minute checks, email alerts. Free tier.
  • Better Uptime : 3-minute checks, heartbeat, status page. 10 monitors free.
  • Uptime Kuma: Self-hosted. Run it in Docker on the same VPS or a separate tiny instance.

Set up a monitor for https://yourdomain.com and configure alerting (email, Telegram, Discord, Slack).


The complete, production-ready stack:

services:
reverse-proxy:
image: traefik:v3.3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
db:
image: postgres:17
restart: always
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
volumes:
- postgres-data:/var/lib/postgresql/data
secrets:
- postgres-password
guestbook:
image: ghcr.io/yourusername/guestbook:prod
restart: always
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgres?sslmode=disable
deploy:
replicas: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.guestbook.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.guestbook.entrypoints=websecure"
- "traefik.http.routers.guestbook.tls.certresolver=letsencrypt"
- "traefik.http.routers.guestbook-http.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.guestbook-http.entrypoints=web"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
- "traefik.http.routers.guestbook-http.middlewares=redirect-to-https"
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
- db
watchtower:
image: containrrr/watchtower
command:
- "--label-enable"
- "--interval"
- "30"
- "--rolling-restart"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
secrets:
postgres-password:
file: ./db/postgres-password.txt
volumes:
postgres-data:
letsencrypt:

Deploy:

Terminal window
export POSTGRES_PASSWORD=$(cat db/postgres-password.txt)
docker compose up -d

TaskRocky Linux 10Ubuntu 26.04
Package installdnf install -y <pkg>apt install -y <pkg>
Package searchdnf search <pkg>apt search <pkg>
Add repodnf config-manager --add-repo <url>add-apt-repository or manual .list
User createuseradd -m <user>adduser <user>
Sudo groupwheelsudo
Firewallfirewalld (firewall-cmd)ufw
Service mgmtsystemctlsystemctl
SELinuxEnforcing by default (getenforce)AppArmor by default
Logsjournalctl -u <unit>journalctl -u <unit>
Croncrondcron
EPEL (extra pkgs)dnf install -y epel-releaseN/A

If SELinux blocks Docker operations (socket access, volume mounts):

Step 1: Find the blocked path. Run ausearch to see recent SELinux denials:

Terminal window
sudo ausearch -m avc -ts recent

Look for the name= field in the output. Example denial and what to look for:

type=AVC msg=audit(1718123456.789:1234): avc: denied { write } for
pid=5678 comm="dockerd" name="postgres-data" dev="sda1" ino=12345
scontext=system_u:system_r:container_t:s0
tcontext=system_u:object_r:default_t:s0 tclass=dir

The blocked path is in the name= field — here it’s postgres-data. You can also find the full path with:

Terminal window
sudo ausearch -m avc -ts recent | grep -oP 'name="?\K[^"\s]+'

Step 2: Apply the fix. Set the SELinux context for the blocked path(s):

Terminal window
# Replace /path/to/mount with the actual path from ausearch output
sudo semanage fcontext -a -t container_file_t "/path/to/mount(/.*)?"
sudo restorecon -Rv /path/to/mount

For Docker named volumes, the path is typically under /var/lib/docker/volumes/<volume-name>/. For bind mounts, use the host path from your docker-compose.yml volumes: section.

Usually Docker and SELinux coexist fine on Rocky 10 with default policies. Only intervene if you see Permission denied in container logs despite correct file permissions.


  • Non-root user created with sudo/wheel
  • SSH: PasswordAuthentication no, PermitRootLogin no
  • Firewall: only 22, 80, 443 open
  • Docker installed, user in docker group
  • App running as docker compose up -d
  • Traefik reverse proxy routing traffic
  • HTTPS active with Let’s Encrypt auto-renewal
  • Multiple app replicas running
  • Watchtower handling rolling updates
  • Uptime monitor configured with alerts

Setting up a production-ready VPS is less intimidating than it looks. Traefik + Watchtower + Docker Compose gives you 90% of what a PaaS offers : with more control, predictable billing, and no vendor lock-in. Both Rocky Linux 10 and Ubuntu 26.04 make excellent foundations; pick the ecosystem you’re most comfortable with.