Build a Personal Blog — Phase 6: Deploy to Ubuntu VPS
vpsdockernginxdevopsubuntu
This is Phase 6 of the Build a Personal Blog series. In Phase 5 you containerized your Next.js blog and PostgreSQL database with Docker Compose. Everything runs beautifully in containers on your laptop. Now it's time to put those containers on a real server and make your blog available to the world.
✅ An Ubuntu VPS on Hostinger with secure SSH access
✅ Docker and Docker Compose installed on the server
✅ Your blog running in production containers on the VPS
✅ Nginx as a reverse proxy forwarding traffic to Next.js
✅ Free SSL certificates from Let's Encrypt via Certbot
✅ A zero-downtime re-deploy workflow you can run in 30 seconds
✅ Firewall and fail2ban protecting your server from brute-force attacks
Here's what we're building — traffic flows from the internet through Nginx to your Docker containers:
Nginx handles SSL termination and forwards HTTP traffic to the Next.js container on port 3000. PostgreSQL runs in its own container, accessible only from the Next.js container — never exposed to the internet.
1. Choose a VPS Plan on Hostinger
Head to Hostinger VPS and pick a plan. For a personal blog, the entry-level plans work fine:
Plan
RAM
CPU
Storage
Price
KVM 1
4 GB
1 vCPU
50 GB NVMe
~$5/month
KVM 2
8 GB
2 vCPU
100 GB NVMe
~$7/month
KVM 1 is more than enough for a personal blog. Next.js standalone uses ~100-150 MB of memory, PostgreSQL takes ~50-100 MB, and Nginx barely registers. You'll have plenty of headroom.
When setting up the VPS:
Choose Ubuntu 24.04 as the OS
Select a data center close to your audience (e.g., US, EU, or Asia)
Set a root password (you'll disable password auth later)
Note down your VPS IP address — you'll need it everywhere
2. Initial Server Setup
2.1 Connect via SSH
From your local machine, SSH into the VPS as root:
ssh root@YOUR_VPS_IP
You'll be asked for the root password you set during VPS creation.
2.2 Create a Deploy User
Running everything as root is dangerous. One wrong rm -rf and your server is gone. Create a dedicated user:
# Create user with home directoryadduser deploy# Add to sudo groupusermod -aG sudo deploy
Choose a strong password when prompted.
2.3 Set Up SSH Key Authentication
SSH keys are more secure than passwords and more convenient. On your local machine (not the VPS):
# Generate an SSH key pair (if you don't have one)ssh-keygen -t ed25519 -C "your-email@example.com"# Copy your public key to the VPSssh-copy-id deploy@YOUR_VPS_IP
Test that it works:
ssh deploy@YOUR_VPS_IP# Should log in without asking for a password
2.4 Disable Password Authentication
Now that SSH keys work, disable password login to prevent brute-force attacks:
Once you see Ready on http://0.0.0.0:3000, your blog is running. Press Ctrl+C to exit the logs.
Verify the containers are healthy:
docker compose ps
Expected output:
NAME SERVICE STATUS PORTSmy-blog-app-1 app Up (healthy) 0.0.0.0:3000->3000/tcpmy-blog-db-1 db Up (healthy) 5432/tcp
At this point your blog is accessible at http://YOUR_VPS_IP:3000 — but only if you've opened port 3000 in the firewall (don't do this in production). Instead, we'll put Nginx in front.
5. Set Up Nginx as a Reverse Proxy
Nginx sits between the internet and your Next.js container. It handles:
SSL termination — decrypts HTTPS, forwards plain HTTP to Next.js
Static file caching — serves headers for browser caching
HTTP/2 — better performance for modern browsers
Security headers — HSTS, X-Frame-Options, etc.
5.1 Install Nginx
sudo apt install -y nginx
Verify it's running:
sudo systemctl status nginx# Should show "active (running)"
Visit http://YOUR_VPS_IP in your browser — you should see the default Nginx welcome page.
5.2 Create the Nginx Configuration
Remove the default site and create one for your blog:
# Remove defaultsudo rm /etc/nginx/sites-enabled/default# Create blog configsudo nano /etc/nginx/sites-available/blog
Replace yourdomain.com with your actual domain (or your VPS IP if you haven't set up a domain yet).
5.3 Enable the Site
# Create symlink to enable the sitesudo ln -s /etc/nginx/sites-available/blog /etc/nginx/sites-enabled/blog# Test configuration for syntax errorssudo nginx -t# Reload Nginxsudo systemctl reload nginx
Expected output from nginx -t:
nginx: the configuration file /etc/nginx/nginx.conf syntax is oknginx: configuration file /etc/nginx/nginx.conf test is successful
Now visit http://YOUR_VPS_IP — you should see your blog instead of the Nginx welcome page!
Why a Reverse Proxy?
You might wonder: "Why not just expose port 3000 directly?" Several reasons:
6. SSL with Let's Encrypt
HTTP is not secure. Anyone between your user and your server can read the traffic. Let's add HTTPS with free SSL certificates from Let's Encrypt.
6.1 Install Certbot
sudo apt install -y certbot python3-certbot-nginx
6.2 Obtain SSL Certificates
Prerequisite: Your domain must already point to your VPS IP before running this command. If you haven't set up DNS yet, skip this section and come back after Phase 7 (Custom Domain Setup). You can use http://YOUR_VPS_IP in the meantime.
Automatically verify domain ownership via HTTP challenge
Install the certificate and update your Nginx config
Set up automatic HTTPS redirect
After Certbot finishes, your Nginx config will be updated with SSL settings. Check the result:
cat /etc/nginx/sites-available/blog
You'll see new listen 443 ssl directives and certificate paths. Certbot handles all of this.
6.3 Verify Auto-Renewal
Let's Encrypt certificates expire every 90 days. Certbot installs a systemd timer for automatic renewal:
# Check the timersudo systemctl status certbot.timer# Test renewal (dry run — doesn't actually renew)sudo certbot renew --dry-run
If the dry run succeeds, you're set. Certbot will renew certificates automatically before they expire.
6.4 Test SSL
Visit https://yourdomain.com and verify:
The padlock icon appears in the browser
HTTP automatically redirects to HTTPS
The certificate is issued by Let's Encrypt
You can also test with the SSL Labs scanner: go to SSL Labs and enter your domain. Aim for an A or A+ rating.
7. Deploy Workflow
Your blog is live! But you'll keep writing posts and making changes. Here's how to deploy updates.
7.1 Quick Deploy Script
Create a deploy script on the VPS:
nano ~/my-blog/deploy.sh
#!/bin/bashset -eecho "🔄 Pulling latest changes..."git pull origin mainecho "🏗️ Building and restarting containers..."docker compose -f docker-compose.yml -f docker-compose.prod.yml \ --env-file .env.production \ up -d --buildecho "🧹 Cleaning up old images..."docker image prune -fecho "✅ Deploy complete!"echo ""docker compose ps
Make it executable:
chmod +x ~/my-blog/deploy.sh
7.2 Deploy from Your Local Machine
The typical workflow:
# On your local machinegit add .git commit -m "Add new blog post"git push origin main# SSH into VPS and deployssh deploy@YOUR_VPS_IP "cd ~/my-blog && ./deploy.sh"
Or combine it into a single command you can alias in your shell:
# Add to ~/.zshrc or ~/.bashrcalias deploy-blog='ssh deploy@YOUR_VPS_IP "cd ~/my-blog && ./deploy.sh"'# Now just run:deploy-blog
7.3 Zero-Downtime Strategy
Docker Compose's up -d --build restarts containers one at a time. For a personal blog, the brief restart (~5-10 seconds) is acceptable. If you need true zero-downtime:
Use health checks — Docker waits for the new container to be healthy before routing traffic to it. Your docker-compose.yml from Phase 5 already has health checks configured.
8. Monitoring and Logs
8.1 View Container Logs
# All containersdocker compose logs -f# Just the Next.js appdocker compose logs -f app# Just PostgreSQLdocker compose logs -f db# Last 100 linesdocker compose logs --tail 100 app
8.2 Check Resource Usage
# Container CPU and memorydocker stats# Disk usagedf -h# Docker disk usagedocker system df
# Check logs for errorsdocker compose logs app# Common issues:# 1. DATABASE_URL wrong — check .env.production# 2. Port 3000 already in use — check with: lsof -i :3000# 3. Build failed — check for TypeScript errors in logs
Nginx shows 502 Bad Gateway
This means Nginx can't reach the Next.js container:
# Is the app container running?docker compose ps# Is it listening on port 3000?curl http://127.0.0.1:3000# Check Nginx error logsudo tail -20 /var/log/nginx/error.log
Common causes:
Container is still starting up (wait a few seconds)
Container crashed — check docker compose logs app
Wrong port in Nginx config — verify proxy_pass http://127.0.0.1:3000
SSL certificate won't issue
Certbot needs to reach your server on port 80 to verify domain ownership:
# Check DNS points to your VPSdig yourdomain.com +short# Should return your VPS IP# Check port 80 is opensudo ufw status# Should show 80/tcp ALLOW# Check Nginx is runningsudo systemctl status nginx
Out of disk space
Docker images and build cache can eat disk space:
# See what's using spacedocker system df# Remove unused images, containers, and build cachedocker system prune -a# Remove old logsdocker compose logs --tail 0
Summary
In this phase you:
✅ Set up an Ubuntu VPS on Hostinger with secure SSH access and a non-root deploy user
✅ Hardened the server with firewall rules and fail2ban brute-force protection
✅ Installed Docker and Docker Compose on the VPS
✅ Deployed your blog with production Docker Compose configuration
✅ Configured Nginx as a reverse proxy with static asset caching
✅ Added free SSL certificates from Let's Encrypt with automatic renewal
✅ Created a deploy script for quick, repeatable deployments
Your blog is now live on the internet, secured with HTTPS, and protected by multiple layers of security. Anyone can visit https://yourdomain.com and read your posts.
What's Next
In Phase 7, you'll register a custom domain (like chanh.blog) on Hostinger, configure DNS records to point to your VPS, set up the www redirect, and verify everything works end-to-end. You'll also submit your site to Google Search Console and test social sharing previews.