Back to blog
Software Architecture

SSH to Production: Provisioning a VPS the Right Way

Abdeldjalile Lafkir2026-05-223 min read

SSH to Production

Every serious deployment story starts the same way: a fresh server, a root shell over SSH, and a thousand ways to break security before you even install anything.

Provisioning a VPS the right way is not glamorous, but it decides whether your production environment survives the first week. This is the checklist I run every time, and it is fully scripted so a new server takes minutes, not an afternoon.

Firewall First

Before anything else, the firewall goes up. The rule is simple: deny everything incoming by default, allow only what the service actually needs.

ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw --force enable

Three open ports. Everything else is closed. If you ever wonder why your server is getting scanned, this baseline is what keeps the scanners from finding anything to poke at.

Never Deploy as Root

The next step is creating a dedicated application user. Root over SSH should be disabled in spirit, and practically, a user with sudo and passwordless authentication is enough for any deployment task.

useradd --create-home --shell /bin/bash app
usermod -aG sudo app
usermod -aG docker app

mkdir -p /home/app/.ssh
chmod 700 /home/app/.ssh
echo "$SSH_PUBLIC_KEY" > /home/app/.ssh/authorized_keys
chmod 600 /home/app/.ssh/authorized_keys
chown -R app:app /home/app

The permissions matter: 700 on the directory, 600 on the key file. A world-readable authorized_keys is a classic finding in every security review.

Repeatable Provisioning

Manual steps get forgotten. So I turned the whole flow into a script that runs from my machine via SSH: update packages, install prerequisites, configure the firewall, create the user, install Docker from the official repository, set the timezone, and scaffold the application directory structure.

The script uses ssh -o StrictHostKeyChecking=accept-new for first connections and runs remote commands as a heredoc with sudo. After it finishes, a single scp transfers the environment file, and deployment is a matter of running the deploy step.

The Deploy Loop

With provisioning scripted, the daily workflow becomes boring in the best way:

scp .env.production app@server:/opt/app/.env
ssh app@server 'docker compose -f /opt/app/docker-compose.yml up -d --no-deps app'

The database starts, migrations run, and the application comes up, all over SSH, all repeatable, all documented.

Conclusion

A hardened server is not a destination, it is a checklist you execute every single time without exceptions: firewall first, keys over passwords, dedicated users, and everything scripted. The moment provisioning becomes code, your production environment stops being a mystery and becomes infrastructure you can rebuild from scratch at any time.

The complete provisioning and deployment scripts live in my GitHub repositories, steal the pattern and adapt it to your stack.