// 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.
This tutorial combines several skills you've learned in our guides:
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.
Your blog will use three "containers" (think virtual machines that are lightweight):
Prerequisites
Before starting this tutorial, make sure you've completed:
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.
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.
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
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"
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.
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.
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
INT AUTO_INCREMENT PRIMARY KEY - A unique number that automatically increases for each new recordVARCHAR(255) - A text field that can hold up to 255 charactersTEXT - A larger text field for long content like blog postsDEFAULT CURRENT_TIMESTAMP - Automatically sets the date/time when a record is createdUNIQUE - Ensures no duplicate values (like two posts with the same URL slug)The password is hashed using PHP's password_hash() function. The string starting with $2y$ is the encrypted version of "admin123".
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:
__construct() - A special method that runs when we create a new Database object. It sets up our connection settings.getenv('DB_HOST') - Gets an environment variable. Docker sets these, or we use the fallback value after ?:PDO - PHP Data Objects - a consistent way to access databasesprepare() and execute() - We use prepared statements to prevent SQL injection (a security vulnerability)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
require_once 'db.php' - Includes the database file so we can use $db$db->fetchAll() - Our helper method runs a SELECT query and returns all matching rowsLEFT JOIN - SQL that combines posts with user data (to get the author's name)htmlspecialchars() - Prevents XSS attacks by converting special characters to safe HTML entitiesforeach - A PHP loop that iterates through each postWhen 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
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
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
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).
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
version: '3.8' - The version of Docker Compose formatimage - The Docker image to use (like installing an app from an app store)container_name - A friendly name for this containerports - Maps a port on your computer to a port in the container (80:80 means localhost:80 goes to container:80)volumes - Connects folders on your computer to folders in the containerdepends_on - Starts services in order (nginx needs PHP to be running first)environment - Sets environment variables (like database passwords)restart: unless-stopped - Automatically restarts if the container crashesnginx 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
listen 80 - Listen on port 80 (standard HTTP)root /var/www/html - The root folder where files are served fromtry_files - If a requested file doesn't exist, try index.php insteadfastcgi_pass - Forward PHP files to the PHP-FPM container on port 9000Now 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.
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.
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.
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:~$
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
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
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
There are two ways to get your code on the server. Method 1 is simpler for beginners.
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
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
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
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!
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://!
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
One of the benefits of using Git is easy updates. Here's how to make changes to your live blog.
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
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
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
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.