The Definitive Guide to a Production-Ready VPS


Jul 23, 2025 See all posts

This guide is for the engineer who wants control without the cost. We will build a secure, automated, and production-ready server using a modern, daemonless container engine: Podman. Forget paying for abstractions; let’s build our own lean and powerful platform.

By the end, you’ll have a fully provisioned server running your services, complete with automatic HTTPS, zero-downtime deployments, and off-site backups—all managed with free, open-source software.

The Stack: Our FOSS Tool-Belt


Part 1: The One-Liner to Rule Them All

Log into your fresh VPS as root. This command will download and run the setup script, which will ask you a few questions and then build everything for you.

bash <(curl -sL https://gist.githubusercontent.com/AnhellO/d652932f91cb61d43a6dd94220021c5f/raw/89fc7596a793a38cd1654854c602a823ed689a79/setup-podman-vps.sh)

The script will prompt you for your desired username, domain name, email, and backup credentials. After that, it takes care of everything.

Part 2: What the Script Does (The Deep Dive)

Understanding the automation is key. Here’s a breakdown of the script’s actions.

Section 1: Core Security & Podman Installation

The script begins by securing the server and installing our container tools.

# from setup-podman-vps.sh

# Install Podman, Podman Compose, and security tools
apt-get update
apt-get install -y podman podman-compose ufw fail2ban

# Enable the Podman socket
# This creates a Docker-compatible API for Traefik and Watchtower to use
systemctl enable --now podman.socket

# Configure the firewall
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh http https
ufw --force enable

Section 2: The Infrastructure Blueprint (compose.yml)

The script generates a compose.yml file in /home/your_user/app. This is the heart of your system. Notice two key changes: the socket path and the placeholder app.

# Generated compose.yml

services:
  reverse-proxy:
    image: docker.io/traefik:v3.1
    # ... Traefik config ...
    volumes:
      # We mount the PODMAN socket, not the docker socket
      - /var/run/podman/podman.sock:/var/run/docker.sock:ro
      - letsencrypt_data:/letsencrypt

  # ... Watchtower and DB services remain similar ...

  # --- YOUR APPLICATION GOES HERE ---
  # A simple placeholder app to verify the setup is working.
  # Replace this with your actual application service.
  whoami:
    image: docker.io/traefik/whoami
    restart: unless-stopped
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`${DOMAIN_NAME}`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=myresolver"
      - "com.centurylinklabs.watchtower.enable=true"

# ... secrets and volumes ...

Section 3: Automated Backups with Restic

This section is largely the same, but the commands are adapted for Podman. The script creates a backup.sh file and a nightly cron job.

# from backup.sh inside the main script

# Dump the database from the 'db' container using podman
echo "Dumping PostgreSQL database..."
podman exec -t $(podman ps --format '{{.Names}}' | grep '_db_') pg_dumpall -U postgres > ${APP_DIR}/backup/dump.sql

The logic is identical: dump the database, then use restic to run an encrypted, incremental backup to your off-site B2 bucket. We just use podman exec instead of docker exec.

Part 3: Launching and Managing Your Platform

Your server is provisioned. The blueprint is ready.

  1. Log in as your new user: ssh your_user@your_domain.com

  2. Navigate to the app directory: cd ~/app

  3. Launch the stack:

    podman-compose up -d
    

Within a minute or two, Traefik will procure a TLS certificate. You can then navigate to https://your_domain.com and you should see the whoami application’s output, confirming everything is working!

When you’re ready to deploy your own app:

  1. Edit compose.yml (vim ~/app/compose.yml).
  2. Remove or comment out the whoami service.
  3. Add the service definition for your own application, making sure to include the correct Traefik and Watchtower labels.
  4. Run podman-compose up -d again. Podman will pull your new image and start it, while Traefik automatically handles the routing.

The Complete setup-podman-vps.sh Script

Here is the final, complete script built to your specifications. It’s ready to turn a fresh Ubuntu server into your personal, production-ready platform.

#!/bin/bash

# A script to automate the setup of a secure, production-ready VPS using Podman.
#
# Features:
# - Creates a new non-root user with sudo privileges.
# - Hardens SSH: Disables root login and password authentication.
# - Installs and configures UFW firewall & Fail2ban.
# - Installs Podman and Podman Compose.
# - Enables the Podman socket for Docker API compatibility.
# - Generates a compose.yml with Traefik, Watchtower, a Postgres DB, and a placeholder app.
# - Sets up automated, encrypted, off-site backups with Restic and Cron.
#
# Supported OS: Ubuntu 24.04 or 22.04 LTS

# --- Script Start ---

set -e # Exit immediately if a command exits with a non-zero status.

# --- Color Codes ---
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color

# --- Functions ---

function initial_checks() {
    if [ "$(id -u)" -ne 0 ]; then
        echo -e "${RED}This script must be run as root.${NC}"
        exit 1
    fi
    if ! grep -q "Ubuntu 22.04\|Ubuntu 24.04" /etc/os-release; then
        echo -e "${RED}This script is designed for Ubuntu 22.04 or 24.04 LTS.${NC}"
        exit 1
    fi
    echo -e "${GREEN}System checks passed. Running on a supported Ubuntu version as root.${NC}"
}

function get_user_input() {
    echo -e "${YELLOW}Please provide the following information:${NC}"
    read -p "Enter a username for the new non-root user: " USERNAME
    read -p "Enter your domain name (e.g., myapp.com): " DOMAIN_NAME
    read -p "Enter your email address (for Let's Encrypt SSL): " USER_EMAIL

    echo -e "\n${YELLOW}For automated off-site backups, you need a Backblaze B2 bucket and credentials.${NC}"
    read -p "Enter your B2 Bucket Name (e.g., myapp-vps-backup): " B2_BUCKET
    read -p "Enter your B2 Application Key ID: " B2_KEY_ID
    read -s -p "Enter your B2 Application Key: " B2_APPLICATION_KEY
    echo
    read -s -p "Enter a strong password for your backup encryption: " RESTIC_PASSWORD
    echo
}

function setup_user_and_ssh() {
    echo -e "\n${GREEN}--- Setting up user and hardening SSH ---${NC}"
    if id "$USERNAME" &>/dev/null; then
        echo -e "${YELLOW}User $USERNAME already exists. Skipping user creation.${NC}"
    else
        adduser --gecos "" "$USERNAME"
        usermod -aG sudo "$USERNAME"
        echo -e "User $USERNAME created and added to the sudo group."
    fi

    mkdir -p "/home/$USERNAME/.ssh"
    cp /root/.ssh/authorized_keys "/home/$USERNAME/.ssh/authorized_keys"
    chown -R "$USERNAME:$USERNAME" "/home/$USERNAME/.ssh"
    chmod 700 "/home/$USERNAME/.ssh"
    chmod 600 "/home/$USERNAME/.ssh/authorized_keys"
    echo "SSH keys copied to new user."

    echo "Hardening SSH configuration..."
    sed -i -e 's/^#PasswordAuthentication yes/PasswordAuthentication no/' \
           -e 's/^PasswordAuthentication yes/PasswordAuthentication no/' \
           -e 's/^#PermitRootLogin prohibit-password/PermitRootLogin no/' \
           -e 's/^PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
    systemctl reload ssh
    echo -e "${GREEN}SSH hardened. Password and root logins are disabled.${NC}"
}

function install_tools() {
    echo -e "\n${GREEN}--- Installing Podman and security tools ---${NC}"
    apt-get update
    apt-get install -y podman podman-compose ufw fail2ban restic

    echo "Enabling Podman socket for API compatibility..."
    systemctl enable --now podman.socket

    echo "Configuring firewall..."
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow ssh
    ufw allow http
    ufw allow https
    ufw --force enable
    echo "UFW firewall enabled."

    systemctl enable fail2ban
    systemctl start fail2ban
    echo "Fail2ban installed and enabled."
}

function generate_podman_stack() {
    echo -e "\n${GREEN}--- Generating Podman stack configuration ---${NC}"
    local APP_DIR="/home/$USERNAME/app"
    mkdir -p "$APP_DIR/secrets"
    mkdir -p "$APP_DIR/backup"

    openssl rand -base64 32 > "$APP_DIR/secrets/db_password.txt"

    cat <<EOF > "$APP_DIR/compose.yml"
# This file was auto-generated by the setup script.
# Replace the 'whoami' service with your own application.

version: '3.7'

services:
  reverse-proxy:
    image: docker.io/traefik:v3.1
    restart: unless-stopped
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.myresolver.acme.tlschallenge=true"
      - "--certificatesresolvers.myresolver.acme.email=${USER_EMAIL}"
      - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
      - "--log.level=INFO"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/podman/podman.sock:/var/run/docker.sock:ro
      - letsencrypt_data:/letsencrypt
    labels:
      - "traefik.http.routers.http-catchall.rule=hostregexp(\`{host:.+}\`)"
      - "traefik.http.routers.http-catchall.entrypoints=web"
      - "traefik.http.routers.http-catchall.middlewares=redirect-to-https"
      - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"

  watchtower:
    image: docker.io/containrrr/watchtower
    restart: unless-stopped
    command: ["--label-enable", "--interval", "300", "--rolling-restart"]
    volumes:
      - /var/run/podman/podman.sock:/var/run/docker.sock
    labels:
      - "com.centurylinklabs.watchtower.enable=false"

  db:
    image: docker.io/postgres:16
    restart: unless-stopped
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    labels:
      - "traefik.enable=false"

  # --- YOUR APPLICATION GOES HERE ---
  # A simple placeholder app to verify the setup is working.
  # Replace this with your actual application service.
  whoami:
    image: docker.io/traefik/whoami
    restart: unless-stopped
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(\`${DOMAIN_NAME}\`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=myresolver"
      - "com.centurylinklabs.watchtower.enable=true"

secrets:
  db_password:
    file: ./secrets/db_password.txt

volumes:
  letsencrypt_data:
  db_data:

EOF

    chown -R "$USERNAME:$USERNAME" "/home/$USERNAME"
    chmod 700 "$APP_DIR/secrets"
    chmod 600 "$APP_DIR/secrets/db_password.txt"

    echo "compose.yml created in $APP_DIR"
}

function setup_backups() {
    echo -e "\n${GREEN}--- Setting up automated off-site backups ---${NC}"
    local APP_DIR="/home/$USERNAME/app"

    cat <<EOF > "$APP_DIR/backup.sh"
#!/bin/bash
set -e

# --- Backup Configuration ---
export RESTIC_REPOSITORY="b2:${B2_BUCKET}:/"
export RESTIC_PASSWORD="${RESTIC_PASSWORD}"
export AWS_ACCESS_KEY_ID="${B2_KEY_ID}"
export AWS_SECRET_ACCESS_KEY="${B2_APPLICATION_KEY}"

# --- Paths to Backup ---
BACKUP_PATHS="${APP_DIR}/secrets ${APP_DIR}/backup"

# --- Main Script ---
echo "Starting backup at \$(date)"

# Dump the database from the 'db' container
echo "Dumping PostgreSQL database..."
DB_CONTAINER_NAME=\$(podman ps --format '{{.Names}}' | grep '_db_')
if [ -z "\$DB_CONTAINER_NAME" ]; then
    echo "Database container not found. Skipping DB dump."
else
    podman exec -t "\$DB_CONTAINER_NAME" pg_dumpall -U postgres > ${APP_DIR}/backup/dump.sql
    echo "Database dump complete."
fi

# Run the backup
echo "Backing up data to B2..."
restic backup \$BACKUP_PATHS
echo "Backup complete."

# Prune old backups (keep last 7 daily, 4 weekly, 12 monthly)
echo "Pruning old backups..."
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
echo "Pruning complete."
echo "Backup finished at \$(date)"
echo "---------------------------------"
EOF

    chmod +x "$APP_DIR/backup.sh"
    chown "$USERNAME:$USERNAME" "$APP_DIR/backup.sh"

    # Set up cron job
    (crontab -u "$USERNAME" -l 2>/dev/null; echo "0 3 * * * /bin/bash $APP_DIR/backup.sh >> $APP_DIR/backup.log 2>&1") | crontab -u "$USERNAME" -
    echo "Cron job for daily backups created."

    # Initialize the restic repository as the new user
    su - "$USERNAME" -c "export RESTIC_REPOSITORY='b2:${B2_BUCKET}:/' && export RESTIC_PASSWORD='${RESTIC_PASSWORD}' && export AWS_ACCESS_KEY_ID='${B2_KEY_ID}' && export AWS_SECRET_ACCESS_KEY='${B2_APPLICATION_KEY}' && restic init"
    echo -e "${GREEN}Restic repository initialized in Backblaze B2. Backups are ready.${NC}"
}

function final_instructions() {
    echo -e "\n\n${GREEN}--- ✅ VPS Setup Complete! ---${NC}"
    echo -e "Your server is now configured with Podman and is ready for your applications."
    echo -e "\n${YELLOW}--- Next Steps ---${NC}"
    echo -e "1. ${YELLOW}Log out of this root session${NC} and log back in as your new user:"
    echo -e "   ssh ${USERNAME}@${DOMAIN_NAME}"
    echo -e "2. Navigate to your application directory:"
    echo -e "   cd ~/app"
    echo -e "3. Start the default stack (Traefik, DB, Watchtower, and a test app):"
    echo -e "   podman-compose up -d"
    echo -e "4. ${YELLOW}Visit https://${DOMAIN_NAME}${NC} after a minute to verify everything is working."
    echo -e "5. When ready, edit ${YELLOW}compose.yml${NC} to replace the 'whoami' service with your own."
    echo -e "\nYour first backup will run automatically at 3 AM server time."
}


# --- Main Execution ---
initial_checks
get_user_input
setup_user_and_ssh
install_tools
generate_podman_stack
setup_backups
final_instructions

Enjoyed the article? I write about 1-2 a month. Subscribe via email or RSS feed.