OVERVIEW LOCAL SETUP THE CODE DOCKER VPS DEPLOY

BUILD YOUR OWN BLOG

// From zero to deployed on your own server

// WHAT WE'RE BUILDING

In this tutorial, you'll create a fully functional personal blog from scratch. You'll write the PHP code yourself, set up a MariaDB database to store your posts, use Docker to package everything, manage your code with Git, and deploy it to a VPS so the whole world can see it.

// WHY THIS MATTERS

Most people use WordPress or hosted platforms like Medium. Those are fine, but you don't own your data, and you're dependent on someone else's infrastructure. When you build your own blog, you own everything. No ads, no tracking, no terms of service changes. Just your words, on your server.

// What You'll Learn

This tutorial combines several skills you've learned in our guides:

How It All Works Together

Think of your blog like a restaurant. MariaDB is the pantry where all ingredients (your posts, user accounts) are stored. PHP is the kitchen - it takes orders (requests from visitors), prepares the food (generates HTML pages), and serves them. Docker is like a food truck - it packs up the entire kitchen and lets you set up anywhere. Your VPS is the physical location where you park the truck and serve customers.

The Architecture

Your blog will use three "containers" (think virtual machines that are lightweight):

Prerequisites

Before starting this tutorial, make sure you've completed:

// Part 1: Set Up Your Local Development Environment

Before we build the blog, we need to set up a development environment on your local machine. This is where you'll write code and test your blog before sending it to the world.

1.1 What is Docker and Why Do We Need It?

Docker is a tool that lets you run applications in isolated containers. Think of a container like a shipping container - it holds everything needed to run your application (code, libraries, settings) in one package. The beauty of Docker is that if your blog works on your computer, it will work exactly the same on your VPS (or anyone else's computer).

Install Docker Desktop from docker.com. Choose the version for your operating system (Windows, Mac, or Linux). Follow the installation wizard - it's straightforward.

1.2 Create Your Project Directory

Open your terminal and create a new folder for your blog project. We'll organize everything in one directory.

$ mkdir -p ~/projects/my-blog
# This creates a folder called "my-blog" in your home directory's "projects" folder
$ cd ~/projects/my-blog
# This changes your current directory to the new folder
$ mkdir -p nginx php
# These create subdirectories for our web server config and PHP files

Let's verify what we created:

$ ls -la
# ls lists files, -la shows all details including hidden files
drwxr-xr-x  5 user  staff  160 Feb 25 10:00 .
$ drwxr-xr-x  5 user  staff  160 Feb 25 10:00 ..
drwxr-xr-x   1 root root  4096 Feb 25 10:00 nginx
drwxr-xr-x   1 root  4096 Feb 25 11:00 php

1.3 Initialize Git

Git is a version control system. It tracks every change you make to your code, so you can go back if something breaks, collaborate with others, and deploy updates to your server.

$ git init
# This initializes a new Git repository in your project folder
Initialized empty Git repository in /Users/you/projects/my-blog/.git/

Now tell Git who you are (replace with your actual name and email):

$ git config user.name "Your Name"
$ git config user.email "you@example.com"

1.4 Create a .gitignore File

The .gitignore file tells Git which files to ignore - ones that shouldn't be tracked or shared. For example, we don't want to commit passwords or temporary files.

$ cat > .gitignore << 'EOF'
# Dependencies
/vendor/

# Environment files (these contain passwords!)
.env
.env.local

# Editor files
.vscode/
.idea/
*.swp

# OS files
.DS_Store
Thumbs.db

# Log files
*.log
EOF

cat is a command that reads files. The >> symbols redirect output to create a file. The << 'EOF' tells the shell "everything after this until you see 'EOF' is the input." This is called a "heredoc" - it's a convenient way to create multi-line text files.

// Part 2: Create the Blog Code

Now we're going to create the actual blog. We'll write PHP code for the frontend (what visitors see) and the backend (database connections, displaying posts). We'll also create a SQL file that sets up our database.

2.1 The Database Schema

First, let's design how our data will be stored. We need tables for:

Create a file called schema.sql that defines our database structure:

$ cat > php/schema.sql << 'EOF'
-- This SQL file defines the structure of our database

-- Create the posts table (articles)
CREATE TABLE IF NOT EXISTS posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    slug VARCHAR(255) NOT NULL UNIQUE,
    content TEXT NOT NULL,
    author_id INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Create the users table
CREATE TABLE IF NOT EXISTS users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    display_name VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Insert a default admin user (password: admin123)
INSERT INTO users (email, password, display_name) 
VALUES ('admin@localhost', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin');
EOF

Understanding the SQL

The password is hashed using PHP's password_hash() function. The string starting with $2y$ is the encrypted version of "admin123".

2.2 Database Connection

Now let's create a PHP file that handles connecting to the database. This is a reusable piece of code that all other PHP files will use.

$ cat > php/db.php << 'EOF'
host = getenv('DB_HOST') ?: 'mariadb';
        $this->db = getenv('DB_NAME') ?: 'blog';
        $this->user = getenv('DB_USER') ?: 'bloguser';
        $this->pass = getenv('DB_PASS') ?: 'blogpass';
    }

    public function getConnection() {
        // If we haven't connected yet, create a new connection
        if ($this->pdo === null) {
            try {
                $dsn = "mysql:host={$this->host};dbname={$this->db};charset=utf8mb4";
                $this->pdo = new PDO($dsn, $this->user, $this->pass, [
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                ]);
            } catch (PDOException $e) {
                die("Connection failed: " . $e->getMessage());
            }
        }
        return $this->pdo;
    }

    // Helper method to run a query
    public function query($sql, $params = []) {
        $stmt = $this->getConnection()->prepare($sql);
        $stmt->execute($params);
        return $stmt;
    }

    // Helper method to get all results
    public function fetchAll($sql, $params = []) {
        return $this->query($sql, $params)->fetchAll();
    }

    // Helper method to get one result
    public function fetchOne($sql, $params = []) {
        return $this->query($sql, $params)->fetch();
    }
}

// Create a global $db instance we can use everywhere
$db = new Database();
EOF

Understanding This Code

This is a PHP class (a blueprint for creating objects). Here's what's happening:

2.3 The Main Blog Page (index.php)

Now let's create the main page that visitors will see - a list of all your published blog posts.

$ cat > php/index.php << 'EOF'
<?php
// Include our database connection
require_once 'db.php';

// Fetch all published posts from the database, newest first
$posts = $db->fetchAll(
    "SELECT p.*, u.display_name as author 
     FROM posts p
     LEFT JOIN users u ON p.author_id = u.id
     ORDER BY p.created_at DESC"
);
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Blog</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>Welcome to My Blog</h1>
        <p>Thoughts on Linux, privacy, and technology</p>
    </header>

    <main>
        <?php if (empty($posts)): ?>
            <p>No posts yet. Check back soon!</p>
        <?php else: ?>
            <?php foreach ($posts as $post): ?>
                <article>
                    <h2>
                        <a href="post.php?slug=<?php echo htmlspecialchars($post['slug']); ?>">
                            <?php echo htmlspecialchars($post['title']); ?>
                        </a>
                    </h2>
                    <p class="meta">
                        <?php echo date('F j, Y', strtotime($post['created_at'])); ?>
                        by <?php echo htmlspecialchars($post['author']); ?>
                    </p>
                    <p><?php echo htmlspecialchars($post['content']); ?></p>
                    <a href="post.php?slug=<?php echo htmlspecialchars($post['slug']); ?>">Read more</a>
                </article>
            <?php endforeach; ?>
        <?php endif; ?>
    </main>

    <footer>
        <p>Built with PHP & Docker. Running on Linux.</p>
    </footer>
</body>
</html>
EOF

Key Concepts

2.4 The Single Post Page (post.php)

When a visitor clicks on a post, they should see the full article. This page displays a single post based on the URL.

$ cat > php/post.php << 'EOF'
<?php
require_once 'db.php';

// Get the slug from the URL (e.g., post.php?slug=my-first-post)
$slug = $_GET['slug'] ?? '';

// Fetch this specific post from the database
$post = $db->fetchOne(
    "SELECT p.*, u.display_name as author 
     FROM posts p
     LEFT JOIN users u ON p.author_id = u.id
     WHERE p.slug = ?",
    [$slug]
);

// If no post found, show 404 error
if (!$post) {
    http_response_code(404);
    echo "<h1>404 - Post not found</h1>";
    echo "<p>The post you're looking for doesn't exist.</p>";
    exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?php echo htmlspecialchars($post['title']); ?></title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1><?php echo htmlspecialchars($post['title']); ?></h1>
        <p class="meta">
            <?php echo date('F j, Y', strtotime($post['created_at'])); ?>
            by <?php echo htmlspecialchars($post['author']); ?>
        </p>
    </header>

    <main>
        <article>
            <p><?php echo nl2br(htmlspecialchars($post['content'])); ?></p>
        </article>
        <p><a href="index.php">← Back to all posts</a></p>
    </main>

    <footer>
        <p>Built with PHP & Docker.</p>
    </footer>
</body>
</html>
EOF

2.5 Simple Styling (style.css)

Let's add some basic CSS to make the blog look decent. We'll keep it simple - clean, readable, and minimal.

$ cat > php/style.css << 'EOF'
:root {
    --ivory: #FFFFF0;
    --white: #FFFFFF;
    --black: #0A0A0A;
    --charcoal: #2A2A2A;
    --beige: #F5F5DC;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: var(--ivory);
    color: var(--black);
    line-height: 1.6;
    margin: 0;
    padding: 0;
}

header {
    background: var(--black);
    color: var(--white);
    padding: 2rem;
    text-align: center;
}

header h1 {
    margin: 0;
    font-size: 2rem;
}

header p {
    margin: 0.5rem 0 0;
    opacity: 0.8;
}

main {
    max-width: 700px;
    margin: 0 auto;
    padding: 2rem;
}

article {
    background: var(--white);
    border: 2px solid var(--black);
    padding: 1.5rem;
    margin-bottom: 1.5rem;
    box-shadow: 4px 4px 0 var(--black);
}

article h2 {
    margin-top: 0;
}

article h2 a {
    color: var(--black);
    text-decoration: none;
}

article h2 a:hover {
    text-decoration: underline;
}

.meta {
    color: var(--charcoal);
    font-size: 0.9rem;
    margin-bottom: 1rem;
}

a {
    color: var(--black);
}

footer {
    background: var(--black);
    color: var(--white);
    text-align: center;
    padding: 1.5rem;
    margin-top: 2rem;
}
EOF

2.6 Admin: Create a New Post

Now we need a way for you to create new blog posts. We'll create a simple admin page.

$ mkdir -p php/admin
$ cat > php/admin/new-post.php << 'EOF'
<?php
require_once '../db.php';

$message = '';

// Handle form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $title = $_POST['title'] ?? '';
    $content = $_POST['content'] ?? '';
    
    // Create a URL-friendly slug from the title
    $slug = strtolower(trim(preg_replace('/[^a-z0-9-]+/', '-', $title), '-'));
    
    // Insert into database (author_id = 1 is our default admin)
    $db->query(
        "INSERT INTO posts (title, slug, content, author_id) VALUES (?, ?, ?, 1)",
        [$title, $slug, $content]
    );
    
    $message = 'Post created successfully!';
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>New Post</title>
    <link rel="stylesheet" href="../style.css">
</head>
<body>
    <header>
        <h1>Create New Post</h1>
    </header>

    <main>
        <?php if ($message): ?>
            <p style="color: green;"><?php echo htmlspecialchars($message); ?></p>
        <?php endif; ?>

        <form method="post">
            <p>
                <label>Title:<br>
                <input type="text" name="title" required style="width: 100%; padding: 0.5rem;">
                </label>
            </p>
            <p>
                <label>Content:<br>
                <textarea name="content" rows="10" required style="width: 100%; padding: 0.5rem;"></textarea>
                </label>
            </p>
            <button type="submit" style="background: var(--black); color: var(--white); padding: 0.75rem 1.5rem; border: none; cursor: pointer;">Publish</button>
            <a href="../index.php" style="margin-left: 1rem;">Cancel</a>
        </form>
    </main>
</body>
</html>
EOF

// Part 3: Dockerize Your Blog

Now we need to tell Docker how to run our blog. We'll create a docker-compose.yml file that defines our three services: nginx (web server), PHP-FPM (PHP processor), and MariaDB (database).

3.1 Create docker-compose.yml

This file tells Docker exactly what containers to create and how to configure them.

$ cat > docker-compose.yml << 'EOF'
version: '3.8'

services:
  # nginx - our web server
  nginx:
    image: nginx:alpine
    container_name: blog-nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx:/etc/nginx/conf.d
      - ./php:/var/www/html
    depends_on:
      - php-fpm
    restart: unless-stopped

  # PHP-FPM - processes PHP code
  php-fpm:
    image: php:8.2-fpm-alpine
    container_name: blog-php
    volumes:
      - ./php:/var/www/html
    depends_on:
      - mariadb
    environment:
      - DB_HOST=mariadb
      - DB_NAME=blog
      - DB_USER=bloguser
      - DB_PASS=blogpass
    restart: unless-stopped

  # MariaDB - our database
  mariadb:
    image: mariadb:10.11
    container_name: blog-mariadb
    environment:
      - MYSQL_ROOT_PASSWORD=rootpass
      - MYSQL_DATABASE=blog
      - MYSQL_USER=bloguser
      - MYSQL_PASSWORD=blogpass
    volumes:
      - mariadb-data:/var/lib/mysql
    restart: unless-stopped

volumes:
  mariadb-data:
EOF

Understanding docker-compose.yml

3.2 Configure nginx

nginx needs to know how to communicate with PHP-FPM. We create a configuration file for this.

$ cat > nginx/default.conf << 'EOF'
server {
    listen 80;
    server_name localhost;
    root /var/www/html;
    index index.php index.html;

    # If file not found, try index.php
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # Forward PHP files to PHP-FPM
    location ~ \.php$ {
        fastcgi_pass php-fpm:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Deny access to hidden files
    location ~ /\. {
        deny all;
    }
}
EOF

nginx Configuration Explained

3.3 Run Your Blog Locally

Now let's start everything up! This is the exciting part - your blog should come to life.

$ docker-compose up -d
# -d means "detached" - run in background
Creating network "my-blog_default" with the default driver
Creating blog-mariadb ... done
Creating blog-php       ... done
Creating blog-nginx     ... done

Check that everything is running:

$ docker-compose ps
NAME        IMAGE          COMMAND              SERVICE    CREATED   STATUS
blog-nginx  nginx:alpine   "/docker-entrypoint…"   nginx     ...       Up
blog-php    php:8.2-fpm    "php-fpm"              php-fpm   ...       Up
blog-mariadb mariadb:10.11  "docker-entrypoint.sh…"  mariadb   ...       Up

All three containers should show "Up". Now let's initialize the database:

$ docker-compose exec mariadb mysql -ubloguser -pblogpass blog < php/schema.sql
# This runs our SQL file to create tables

Open your browser and go to http://localhost. You should see your blog! It will be empty at first - let's add a post.

$ docker-compose exec mariadb mysql -ubloguser -pblogpass blog -e "
INSERT INTO posts (title, slug, content, author_id) 
VALUES ('My First Post', 'my-first-post', 'Hello world! This is my first blog post. Im excited to share my thoughts on Linux, privacy, and technology.', 1);
"

Refresh your browser - you should see your first post! Now visit http://localhost/admin/new-post.php to create more posts through the admin interface.

3.4 Stopping Your Blog

When you're done, you can stop the containers:

$ docker-compose down
# This stops and removes the containers

Your data is safe in the "mariadb-data" volume. When you run docker-compose up -d again, your posts will still be there.

// Part 4: Deploy to Your VPS

Your blog works locally. Now let's put it on the internet! We'll use a VPS (Virtual Private Server) - essentially a computer running in a data center that you control.

4.1 Connect to Your VPS

You need to SSH (Secure Shell) into your VPS. This is like remotely controlling another computer through the terminal.

$ ssh your-username@your-vps-ip-address
# Replace with your actual username and IP address
The authenticity of host 'your-vps-ip (x.x.x.x)' can't be established.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added 'your-vps-ip' (ECDSA) to the list of known hosts.
your-username@vps:~$

You're now logged into your VPS! The prompt changed to show your-username@vps:~$

4.2 Install Docker on Your VPS

Docker needs to be installed on your server just like it is on your local machine. Run these commands on your VPS:

your-username@vps:~$ sudo apt update
# Update package lists
your-username@vps:~$ sudo apt install -y apt-transport-https ca-certificates curl gnupg lsb-release
# Install prerequisites
your-username@vps:~$ curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Add Docker's GPG key
your-username@vps:~$ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Add Docker repository
your-username@vps:~$ sudo apt update
your-username@vps:~$ sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Install Docker

4.3 Add Your User to the Docker Group

This lets you run Docker without typing sudo every time:

your-username@vps:~$ sudo usermod -aG docker $USER
# Add current user to docker group

Log out and log back in for this to take effect, or run:

your-username@vps:~$ newgrp docker

4.4 Set Up Your Firewall

Configure UFW (Uncomplicated Firewall) to allow web traffic:

your-username@vps:~$ sudo ufw allow 22/tcp
# Allow SSH
your-username@vps:~$ sudo ufw allow 80/tcp
# Allow HTTP
your-username@vps:~$ sudo ufw allow 443/tcp
# Allow HTTPS
your-username@vps:~$ sudo ufw enable
# Turn on the firewall

4.5 Transfer Your Code to the VPS

There are two ways to get your code on the server. Method 1 is simpler for beginners.

Method 1: Using Git (Recommended)

First, create a repository Then on on GitHub. your local machine:

$ cd ~/projects/my-blog
$ git add .
# Stage all files
$ git commit -m "Initial blog commit"
# Create a commit
$ git remote add origin git@github.com:yourusername/your-blog-repo.git
# Add your GitHub repo
$ git push -u origin main
# Upload to GitHub

Now on your VPS, clone the repository:

your-username@vps:~$ cd ~
your-username@vps:~$ git clone git@github.com:yourusername/your-blog-repo.git my-blog
# Clone your repo

Method 2: Using SCP

If you don't want to use Git, you can copy files directly:

$ scp -r ~/projects/my-blog/* your-username@your-vps-ip:~/my-blog/
# Copy all files to VPS

4.6 Run Docker on Your VPS

Now let's start your blog on the server:

your-username@vps:~$ cd ~/my-blog
your-username@vps:~$ docker-compose up -d
# Start the containers
your-username@vps:~$ docker-compose exec mariadb mysql -ubloguser -pblogpass blog < php/schema.sql
# Set up the database

4.7 Add Your First Post

Create your first post on the live server:

your-username@vps:~$ docker-compose exec mariadb mysql -ubloguser -pblogpass blog -e "
INSERT INTO posts (title, slug, content, author_id) 
VALUES ('Hello World', 'hello-world', 'My blog is now live! Hello from my VPS!', 1);
"

Visit your VPS IP address in a browser. Your blog is live!

4.8 Set Up SSL (HTTPS)

Let's add free SSL certificates using Let's Encrypt. This encrypts traffic between visitors and your server.

your-username@vps:~$ sudo apt install -y certbot python3-certbot-nginx
your-username@vps:~$ sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Replace with your actual domain

Follow the prompts - enter your email, agree to terms, and choose whether to redirect HTTP to HTTPS. Your blog will now work with https://!

4.9 Automatic Renewals

Let's Encrypt certificates expire after 90 days. Let's set up automatic renewal:

your-username@vps:~$ sudo certbot renew --dry-run
# Test renewal works

// Part 5: Updating Your Blog

One of the benefits of using Git is easy updates. Here's how to make changes to your live blog.

5.1 Make Changes Locally

Edit your files on your local machine, test with docker-compose, then commit and push:

$ git add .
$ git commit -m "Updated styling"
$ git push origin main

5.2 Pull Updates on VPS

On your server, pull the latest changes and restart:

your-username@vps:~$ cd ~/my-blog
your-username@vps:~$ git pull origin main
your-username@vps:~$ docker-compose restart

5.3 Backup Your Database

Always backup before major changes. Create a backup script:

your-username@vps:~$ mkdir -p ~/backups
your-username@vps:~$ docker-compose exec -T mariadb mysqldump -ubloguser -pblogpass blog > ~/backups/blog-$(date +%Y%m%d).sql
# Creates a backup file with today's date

// Summary

Congratulations! You've built and deployed a complete personal blog. Here's what you accomplished:

Next Steps to Explore:

This blog is fully yours. You control the code, the data, and the server. No proprietary platforms, no tracking, no ads. Just your words, hosted on your infrastructure.

The revolution will not be proprietary.