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.
docker-compose
equivalent for the Podman ecosystem.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.
Understanding the automation is key. Here’s a breakdown of the script’s actions.
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
podman
and its compose counterpart directly from Ubuntu’s repositories.podman.socket
command starts a service that provides a Docker-compatible API. This allows Traefik and Watchtower to work with Podman seamlessly.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 ...
/var/run/podman/podman.sock
.whoami
): Instead of a specific app, we use traefik/whoami
. This is a tiny web server that simply displays the details of the HTTP request it receives. It’s the perfect tool to confirm that your domain is pointing correctly and Traefik is handling TLS and routing perfectly.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
.
Your server is provisioned. The blueprint is ready.
Log in as your new user: ssh your_user@your_domain.com
Navigate to the app directory: cd ~/app
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:
compose.yml
(vim ~/app/compose.yml
).whoami
service.podman-compose up -d
again. Podman will pull your new image and start it, while Traefik automatically handles the routing.setup-podman-vps.sh
ScriptHere 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.