Back to blog

Build a Personal Blog — Phase 6: Deploy to Ubuntu VPS

vpsdockernginxdevopsubuntu
Build a Personal Blog — Phase 6: Deploy to Ubuntu VPS

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.

Series: Build a Personal Blog — Complete Roadmap
Previous: Phase 5 — Docker Compose
Next: Phase 7 — Custom Domain Setup on Hostinger
Source Code: GitHub — personal-blog-phase-6


What You'll Build

By the end of this phase:

✅ 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

Time commitment: 3–4 hours
Prerequisites: Phase 5 — Docker Compose


Architecture Overview

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:

PlanRAMCPUStoragePrice
KVM 14 GB1 vCPU50 GB NVMe~$5/month
KVM 28 GB2 vCPU100 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:

  1. Choose Ubuntu 24.04 as the OS
  2. Select a data center close to your audience (e.g., US, EU, or Asia)
  3. Set a root password (you'll disable password auth later)
  4. 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 directory
adduser deploy
 
# Add to sudo group
usermod -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 VPS
ssh-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:

sudo nano /etc/ssh/sshd_config

Find and change these lines:

PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes

Restart SSH:

sudo systemctl restart ssh

Warning: Make sure SSH key login works BEFORE disabling password auth. If you lock yourself out, you'll need Hostinger's VPS console to recover.

2.5 Configure the Firewall

Ubuntu comes with ufw (Uncomplicated Firewall). Allow only the ports you need:

# Allow SSH (so you don't lock yourself out!)
sudo ufw allow OpenSSH
 
# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
 
# Enable the firewall
sudo ufw enable
 
# Verify
sudo ufw status

Expected output:

Status: active
 
To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere
443/tcp                    ALLOW       Anywhere
OpenSSH (v6)               ALLOW       Anywhere (v6)
80/tcp (v6)                ALLOW       Anywhere (v6)
443/tcp (v6)               ALLOW       Anywhere (v6)

2.6 Install fail2ban

fail2ban monitors log files and bans IPs that show malicious signs (like repeated failed login attempts):

sudo apt update
sudo apt install -y fail2ban

Create a local configuration:

sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local

Find the [sshd] section and ensure it's enabled:

[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 5
bantime = 3600

This bans any IP that fails SSH login 5 times for 1 hour. Start the service:

sudo systemctl enable fail2ban
sudo systemctl start fail2ban
 
# Check status
sudo fail2ban-client status sshd

3. Install Docker on the VPS

Docker isn't pre-installed on most Ubuntu images. Here's the official installation method:

# Remove any old Docker packages
sudo apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null
 
# Install prerequisites
sudo apt update
sudo apt install -y ca-certificates curl gnupg
 
# Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
 
# Add Docker repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
 
# Install Docker Engine + Compose plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Add your deploy user to the docker group so you don't need sudo for every Docker command:

sudo usermod -aG docker deploy

Log out and log back in for the group change to take effect:

exit
ssh deploy@YOUR_VPS_IP

Verify Docker works:

docker --version
# Docker version 27.x.x
 
docker compose version
# Docker Compose version v2.x.x
 
# Test with hello-world
docker run hello-world

4. Clone and Configure the Project

4.1 Clone the Repository

cd ~
git clone https://github.com/YOUR_USERNAME/my-blog.git
cd my-blog

Tip: If your repo is private, you can either:

  • Use SSH keys: Add the VPS's public key as a deploy key in GitHub settings
  • Use HTTPS with a personal access token: git clone https://TOKEN@github.com/user/repo.git
  • Use GitHub CLI: gh auth login then gh repo clone user/repo

4.2 Create the Production Environment File

Create .env.production with your production settings:

nano .env.production
# Database
DATABASE_URL=postgresql://bloguser:YOUR_STRONG_PASSWORD@db:5432/myblog
POSTGRES_USER=bloguser
POSTGRES_PASSWORD=YOUR_STRONG_PASSWORD
POSTGRES_DB=myblog
 
# Application
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
NODE_ENV=production

Security: Use a strong, random password for PostgreSQL. Generate one with:

openssl rand -base64 32

4.3 Build and Start the Containers

docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.production up -d --build

Let's break this command down:

FlagPurpose
-f docker-compose.ymlBase configuration
-f docker-compose.prod.ymlProduction overrides (restart policies, logging)
--env-file .env.productionLoad production environment variables
-dDetached mode (runs in background)
--buildRebuild images from source

Watch the build progress:

docker compose logs -f

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                  PORTS
my-blog-app-1   app       Up (healthy)       0.0.0.0:3000->3000/tcp
my-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 default
sudo rm /etc/nginx/sites-enabled/default
 
# Create blog config
sudo nano /etc/nginx/sites-available/blog

Paste this configuration:

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;
 
    # Proxy all requests to Next.js
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
 
        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
 
    # Cache static assets
    location /_next/static/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_cache_valid 200 365d;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
 
    # Cache images
    location /images/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_cache_valid 200 30d;
        add_header Cache-Control "public, max-age=2592000";
    }
}

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 site
sudo ln -s /etc/nginx/sites-available/blog /etc/nginx/sites-enabled/blog
 
# Test configuration for syntax errors
sudo nginx -t
 
# Reload Nginx
sudo systemctl reload nginx

Expected output from nginx -t:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: 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.

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot will:

  1. Ask for your email (for renewal notifications)
  2. Ask you to agree to terms of service
  3. Automatically verify domain ownership via HTTP challenge
  4. Install the certificate and update your Nginx config
  5. 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 timer
sudo 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/bash
set -e
 
echo "🔄 Pulling latest changes..."
git pull origin main
 
echo "🏗️  Building and restarting containers..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production \
  up -d --build
 
echo "🧹 Cleaning up old images..."
docker image prune -f
 
echo "✅ 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 machine
git add .
git commit -m "Add new blog post"
git push origin main
 
# SSH into VPS and deploy
ssh 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 ~/.bashrc
alias 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:

  1. Build the image first (no downtime during build):
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production \
  build
 
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production \
  up -d
  1. 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 containers
docker compose logs -f
 
# Just the Next.js app
docker compose logs -f app
 
# Just PostgreSQL
docker compose logs -f db
 
# Last 100 lines
docker compose logs --tail 100 app

8.2 Check Resource Usage

# Container CPU and memory
docker stats
 
# Disk usage
df -h
 
# Docker disk usage
docker system df

8.3 Nginx Logs

# Access logs
sudo tail -f /var/log/nginx/access.log
 
# Error logs
sudo tail -f /var/log/nginx/error.log

8.4 Restart if Something Goes Wrong

# Restart all containers
docker compose restart
 
# Restart just the app
docker compose restart app
 
# Nuclear option: stop everything and rebuild
docker compose down
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production \
  up -d --build

9. Security Hardening Checklist

Your server is already more secure than most (SSH keys, firewall, fail2ban), but here are additional steps:

9.1 Automatic Security Updates

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Select "Yes" when prompted. Ubuntu will automatically install security patches.

9.2 Don't Expose PostgreSQL

In docker-compose.prod.yml, make sure PostgreSQL is NOT mapped to a host port:

# ✅ Correct — only accessible from other containers
db:
  ports: []
 
# ❌ Wrong — accessible from the internet
db:
  ports:
    - "5432:5432"

The Docker Compose config from Phase 5 already handles this correctly with the production override.

9.3 Keep Docker Updated

# Update Docker alongside system packages
sudo apt update && sudo apt upgrade -y

9.4 Security Summary

LayerProtectionStatus
SSHKey-only auth, no root login
FirewallOnly ports 22, 80, 443 open
Brute-forcefail2ban monitoring SSH
DatabaseNot exposed to internet
SSL/TLSLet's Encrypt with auto-renewal
OSUnattended security updates
ContainersNon-root user in Dockerfile

Troubleshooting

Container won't start

# Check logs for errors
docker 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 log
sudo 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 VPS
dig yourdomain.com +short
# Should return your VPS IP
 
# Check port 80 is open
sudo ufw status
# Should show 80/tcp ALLOW
 
# Check Nginx is running
sudo systemctl status nginx

Out of disk space

Docker images and build cache can eat disk space:

# See what's using space
docker system df
 
# Remove unused images, containers, and build cache
docker system prune -a
 
# Remove old logs
docker 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.

Next Post: Phase 7 — Custom Domain Setup on Hostinger


Series Index

PostTitleStatus
BLOG-1Build a Personal Blog — Roadmap✅ Complete
BLOG-2Phase 1: Project Setup — Next.js 16 + ShadCN/UI✅ Complete
BLOG-3Phase 2: MDX On-Demand Rendering✅ Complete
BLOG-4Phase 3: PostgreSQL + Drizzle ORM✅ Complete
BLOG-5Phase 4: Tags, Search & Pagination✅ Complete
BLOG-6Phase 5: Docker Compose✅ Complete
BLOG-7Phase 6: Deploy to Ubuntu VPS on Hostinger✅ You are here
BLOG-8Phase 7: Custom Domain Setup on Hostinger✅ Complete

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.