<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Josef Strzibny</title>
    <description>The latest articles on DEV Community by Josef Strzibny (@strzibny).</description>
    <link>https://dev.to/strzibny</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F154689%2F89190986-4d70-4c80-8958-ef6407185146.jpeg</url>
      <title>DEV Community: Josef Strzibny</title>
      <link>https://dev.to/strzibny</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/strzibny"/>
    <language>en</language>
    <item>
      <title>How to setup your first Ubuntu VPS for self hosting</title>
      <dc:creator>Josef Strzibny</dc:creator>
      <pubDate>Mon, 11 May 2026 08:33:20 +0000</pubDate>
      <link>https://dev.to/serpapi/how-to-setup-your-first-ubuntu-vps-for-self-hosting-ke2</link>
      <guid>https://dev.to/serpapi/how-to-setup-your-first-ubuntu-vps-for-self-hosting-ke2</guid>
      <description>&lt;p&gt;Running your own server on the internet looks challenging, especially if you have never done that. It comes with security risks, performance considerations, and other challenges. It's not surprising that most people therefore opt for hosted solutions or managed platforms to run their applications without thinking too much about the underlying infrastructure.&lt;/p&gt;

&lt;p&gt;So what does it take to start self hosting on your own with a Virtual Private Server (VPS)? This guide will walk you through a simple Ubuntu 26.04 LTS setup you can start with. We'll look on the most important considerations when it comes to server configuration whether you are going to deploy Docker Compose, Kamal, Coolify, ONCE, or anything else you'll run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff01nimuheqepq9bffqun.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff01nimuheqepq9bffqun.png" alt="The Ubuntu 26.04 " width="500" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Server configuration software
&lt;/h2&gt;

&lt;p&gt;There are a few common ways to set up a server. The right approach will depend on your needs. One of the biggest concerns is how many machines are you going to configure and how often.&lt;/p&gt;

&lt;p&gt;The most straightforward way to configure a server is to &lt;strong&gt;SSH into the server and configure it by hand&lt;/strong&gt;. That's fine for learning, and it's often how people start. The downside is that manual work is easy to forget and hard to reproduce. Three months later you may not remember which packages you installed, which config files you changed, or why one server behaves differently from another.&lt;/p&gt;

&lt;p&gt;You might have seen sysadmins use one of the many &lt;strong&gt;configuration management tools&lt;/strong&gt;. Some of the most famous ones are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ansible&lt;/strong&gt; — an agentless tool that connects over SSH and uses YAML playbooks to describe what should be installed and configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chef&lt;/strong&gt; — a Ruby-based system built around recipes and cookbooks, usually with an agent running on each managed server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Puppet&lt;/strong&gt; — another long-running configuration management tool, built around a declarative language for describing system state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SaltStack&lt;/strong&gt; — a flexible tool that can run with or without agents and is known for managing large fleets quickly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These tools are excellent when you manage many servers and need consistent, repeatable state everywhere. They help with idempotency, drift detection, orchestration, and auditing. But for a first VPS, they can be more complexity than you need.&lt;/p&gt;

&lt;p&gt;It's good to know what you are doing before adopting a higher level tool to automate it. And as we'll see soon enough, you can start with relatively small number of changes that don't even warrant using a complex tool.&lt;/p&gt;

&lt;p&gt;So we'll use basic Bash commands, but stack them together in a &lt;strong&gt;shell script&lt;/strong&gt;. A script is easy to read, easy to run, and requires no extra infrastructure. Even better, VPS providers offer you to kickstart your server with something they often call &lt;em&gt;user data&lt;/em&gt; and which is technically cloud-init underneath.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F474au8i5xd9l6lw15os0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F474au8i5xd9l6lw15os0.png" alt="Creating a virtual server on Digital Ocean" width="800" height="472"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Server provisioning with cloud-init
&lt;/h2&gt;

&lt;p&gt;Cloud-init is the standard way to configure a cloud server on first boot. When you create a VPS at providers like DigitalOcean, Hetzner, Linode, or most larger clouds, you usually get a field for &lt;strong&gt;user data&lt;/strong&gt;. Whatever you put there is passed to the new instance and executed during startup.&lt;/p&gt;

&lt;p&gt;Cloud-init can process YAML configuration, but it can also run a regular shell script. For this guide, a shell script is the easiest format as you can read it from top to bottom, paste it into a provider's user-data field, or run it manually as root on a fresh server. It's good to design it for idempotency, though, so it can be rerun later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvo84vmbu9wh7guj4jzym.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvo84vmbu9wh7guj4jzym.png" alt="Initialization script step at Digital Ocean" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Main considerations
&lt;/h3&gt;

&lt;p&gt;Let's talk about the main things to consider when starting out as the number of configuration options on a Linux box is limitless. The most important set of changes are security-oriented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disabling password-based authentication&lt;/strong&gt; to protect unwanted access to the server, ideally paired with limiting access from private network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protecting SSH port from brute-force attacks&lt;/strong&gt; to slow down repeated SSH login attempts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setting up automatic security updates&lt;/strong&gt; so important patches are applied without you logging in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuring firewall&lt;/strong&gt; to expose only the ports you need on for the networks you trust, ideally paired with cloud (provider's) firewall.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optionally, you can set up &lt;strong&gt;a non-root user&lt;/strong&gt; for day-to-day administration, disable &lt;strong&gt;root SSH login&lt;/strong&gt; altogether, use SSH keys &lt;strong&gt;with passwords&lt;/strong&gt; , or allow only &lt;strong&gt;SSH private network connections&lt;/strong&gt; in your firewall.&lt;/p&gt;

&lt;p&gt;If you are willing to use a 3rd-party service, &lt;a href="https://tailscale.com" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; is a popular choice today to secure the access to your servers. Tailscale creates a mesh network between your computer and your servers for direct communication letting you close public access to your SSH port.&lt;/p&gt;

&lt;p&gt;Other non-security related changes you might consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adding Swap space&lt;/strong&gt; to make small VPS plans more forgiving under memory pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installing Docker&lt;/strong&gt; and other essential software and utilities.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Remember there is way more you tune from more advance SSH configuration to AppArmor or file descriptors. But this is a good start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup script
&lt;/h3&gt;

&lt;p&gt;We'll need to translate these considerations into a reusable script we can pass as &lt;em&gt;user data&lt;/em&gt; during the VPS setup. To start we will prepend it with shebang pointing to &lt;code&gt;env bash&lt;/code&gt; which is a how to run the right Bash binary (remember that Ubuntu doesn't use Bash as default for users, but we'll use Bash as it's the most common shell). We'll follow it up with running the &lt;code&gt;set&lt;/code&gt; command to set certain modes to run the script with.&lt;/p&gt;

&lt;p&gt;The script starts would then look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;set -euo pipefail&lt;/code&gt; line makes the script more strict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;set -e&lt;/code&gt; stops the script when a command fails.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;set -u&lt;/code&gt; stops the script when it references an unset variable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;set -o pipefail&lt;/code&gt; makes a pipeline fail if any command in it fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is better to stop early than to continue with a half-configured server. Then before running any specific commands, we'll update the system to latest packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;DEBIAN_FRONTEND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;noninteractive apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;apt update&lt;/code&gt; step matters on a fresh cloud image because package indexes can be stale. Without it, package installation may fail even though the package exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your first server configuration
&lt;/h2&gt;

&lt;p&gt;Now let's go through the actual configuration we are going to run in more detail.&lt;/p&gt;

&lt;h3&gt;
  
  
  SSH Keys
&lt;/h3&gt;

&lt;p&gt;Secure Shell (SSH) is like regular shell on your system, but used to access other systems on the network as if you are the local user. Similarly to a local login, you need to authenticate when accessing remote server. They are two ways to authenticate with SSH; by using passwords or SSH keys.&lt;/p&gt;

&lt;p&gt;The password-based authentication is not the secure option today, so the first step for a new box would be to set up password-less authentication using SSH keys. This is usually very simple with modern VPS providers as they offer this option in their provisioning setup when creating a new server.&lt;/p&gt;

&lt;p&gt;If you don't have any keys yet, run the following &lt;strong&gt;ssh-keygen&lt;/strong&gt; command and follow its instructions (choosing a password is better but some system admins prefer password-less keys):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; “admin@example.com&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the end you should end up with two keys. The public key will end with &lt;code&gt;.pub&lt;/code&gt; and is the one you'll upload on the server. The private one should always stay safe with you and shouldn't be shared.&lt;/p&gt;

&lt;p&gt;To completely disable password authentication on the system we need to set &lt;code&gt;PasswordAuthentication&lt;/code&gt; to &lt;code&gt;no&lt;/code&gt;. This can be done by changing the &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; config file directly or by appending a file into &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The following disables password logins entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/ssh/sshd_config.d/91-require-ssh-keys.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
PasswordAuthentication no
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;sshd &lt;span class="nt"&gt;-t&lt;/span&gt;
systemctl restart ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;sshd -t&lt;/code&gt; command validates the SSH configuration before restarting the service. If you make a typo, the validation fails and you wan't end up with broken configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  fail2ban
&lt;/h3&gt;

&lt;p&gt;Using SSH keys instead of passwords is likely the most important thing to do for a new Linux box, but since SSH ports are often a target, it make sense to monitor who's trying to log in and ban them from trying again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;fail2ban&lt;/strong&gt; watches authentication logs and temporarily bans IP addresses after too many failed attempts. On modern Ubuntu systems, fail2ban should read SSH logs from journald. What I like the most, is the setup. By just installing the package and running the service, you already get a decent default configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;fail2ban
systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;fail2ban.service
systemctl restart fail2ban.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we want to make changes to the configuration we can do so by editing the fail2ban's &lt;em&gt;sshd.local&lt;/em&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fail2ban/jail.d/sshd.local
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;[sshd]&lt;br&gt;
    enabled = true&lt;br&gt;
    backend = systemd&lt;br&gt;
    maxretry = 5&lt;br&gt;
    findtime = 10m&lt;br&gt;
    bantime = 10m&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;enabled = true&lt;/code&gt; line makes the SSH jail explicit instead of relying on distribution defaults. This configuration bans an IP for ten minutes after five failed attempts within ten minutes. That is a reasonable starting point for a first server.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Remember that (re)starting matters since enabling service will only start it on next reboot.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Automatic Updates
&lt;/h3&gt;

&lt;p&gt;Running package and software updates is a daily bread of system management. We can make our life a bit easier with automated updates to patch vulnerabilities automatically.&lt;/p&gt;

&lt;p&gt;To enable unattended upgrades, we have to first install the &lt;em&gt;unattended-upgrades&lt;/em&gt; package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ubuntu's &lt;code&gt;unattended-upgrades&lt;/code&gt; package can install security updates automatically when configured to do so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/apt/apt.conf.d/20auto-upgrades
systemctl restart unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two configuration lines tell Ubuntu to refresh package lists daily and apply unattended upgrades. By default, Ubuntu limits this mainly to security updates from official repositories, so this is not the same as blindly upgrading every package to a new major version overnight.&lt;/p&gt;

&lt;p&gt;Remember that new package versions will only be running after the process restarts and so some key systems components might need a full reboot occasionally.&lt;/p&gt;

&lt;p&gt;You can check if you system needs rebooting by running the following &lt;em&gt;reboot-required&lt;/em&gt; program:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/var/run/reboot-required
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Schedule a reboot during a maintenance window when required.&lt;/p&gt;

&lt;p&gt;Automatic updates do not replace long-term maintenance as you will still need to upgrade the OS before its support window ends. But they cover the dangerous gap between the day a security update is released and the next time you remember to log in to run updates by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firewall
&lt;/h3&gt;

&lt;p&gt;A firewall is a security system that monitors and controls network traffic. It allows or denies connections based on predefined rules. &lt;strong&gt;Firewall rules&lt;/strong&gt; are mostly about narrowing connections to allowed ports and protocols. Each cloud instance can come with its own firewall and each Linux box will also come with a system firewall.&lt;/p&gt;

&lt;p&gt;Creating firewall rules is easier than it looks once you understand what you need to do. &lt;strong&gt;Cloud firewalls&lt;/strong&gt; also often make it easy by letting you assign resources you have already without remembering their exact IP address. You should also set up a &lt;strong&gt;system firewall&lt;/strong&gt; , even if less strict (it's easier to add rules in your provider's interface and not lock yourself up).&lt;/p&gt;

&lt;p&gt;Here's an example of firewall rules for a single server Kamal deployment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg81szldf3o82o7i9ug99.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg81szldf3o82o7i9ug99.png" alt="Example rules with or without Tailscale" width="800" height="491"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A good rule of thumb is to deny all connections by default and allow connections we expect to have. Instead of exposing every service you are running and hoping each one is safe, you deny incoming traffic by default and open only the ports you actually need.&lt;/p&gt;

&lt;p&gt;So what do you usually need? The ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt; are for public HTTP and HTTPS traffic. The port &lt;code&gt;22&lt;/code&gt; is for SSH. Those need to be reachable from outside if you want to serve web traffic and administer the machine remotely.&lt;/p&gt;

&lt;p&gt;Then you'll need to allow incoming connections for your services. Since they usually only need to be accessed internally, you should limit them to your private subnet (IP addresses that are accessed only from your network of servers).&lt;/p&gt;

&lt;p&gt;Some popular ports include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;3000&lt;/code&gt; for an application server&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;3306&lt;/code&gt; for MySQL or MariaDB&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;5432&lt;/code&gt; for PostgreSQL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;6379&lt;/code&gt; for Redis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you aren't exactly sure about your private subnet address space, it's usually save to default to &lt;code&gt;10.0.0.0/16&lt;/code&gt; although you should always recheck with your provider.&lt;/p&gt;

&lt;p&gt;Cloud firewall are set directly in the provider's admin area:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqtcf86bauqa0ql1vupmf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqtcf86bauqa0ql1vupmf.png" alt="Adding an inbound rule in Digital Ocean firewall" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The exact location depends on the provider, could be under VM settings or separately under Security or Firewalls. You can sometimes design the rules based on descriptive names instead of raw data (HTTPS instead of 443, All IPv4 addresses instead of 0.0.0.0/0).&lt;/p&gt;

&lt;p&gt;For, Ubuntu's UFW (the system firewall) we can set the rules with &lt;code&gt;ufw&lt;/code&gt; commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw default deny incoming
ufw default allow outgoing
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 22/tcp
ufw allow from &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt; to any port 3000 proto tcp
ufw allow from &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt; to any port 3306 proto tcp
ufw allow from &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt; to any port 5432 proto tcp
ufw allow from &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt; to any port 6379 proto tcp
ufw logging on
ufw &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the model you usually want: public web traffic comes in through HTTP/HTTPS, while databases and internal services stay on a private network. Your database should not be directly reachable from the public internet. You need to update this to your exact setup.&lt;/p&gt;

&lt;p&gt;UFW uses &lt;strong&gt;nftables&lt;/strong&gt; under the hood on current Ubuntu releases, but the user-facing commands are the same. You can keep using simple &lt;code&gt;ufw allow&lt;/code&gt;, &lt;code&gt;ufw deny&lt;/code&gt;, and &lt;code&gt;ufw status&lt;/code&gt; commands without learning nftables directly. The &lt;code&gt;ufw logging on&lt;/code&gt; line is useful when something cannot connect and you need to see whether the firewall is blocking it.&lt;/p&gt;

&lt;p&gt;Opening SSH to the whole internet is simple, but it is not the only option. If you have a stable home IP, you can restrict SSH to that address. If you use Tailscale, you can eventually move administration onto the private network. For a first server, global SSH plus key-only login and fail2ban is a practical starting point.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Note that Docker will override UFW on Ubuntu by default, so be careful what you are exposing when running Docker-based services.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Non-root user
&lt;/h3&gt;

&lt;p&gt;It's better not to use the &lt;em&gt;root&lt;/em&gt; user for everyday work as it's a privileged user that can do anything on the system. It's also the obvious attack vector for any malicious actor stealing your SSH private key.&lt;/p&gt;

&lt;p&gt;We can create a non-root user with &lt;code&gt;sudo&lt;/code&gt; access which gives the user the superpowers it needs to manage everything like &lt;em&gt;root&lt;/em&gt;, but would require another information for the attacker to know.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=[&lt;/span&gt;USERNAME]
useradd &lt;span class="nt"&gt;--create-home&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt;
usermod &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash &lt;span class="nv"&gt;$USER&lt;/span&gt;
su - &lt;span class="nv"&gt;$USER&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'mkdir -p ~/.ssh'&lt;/span&gt;
su - &lt;span class="nv"&gt;$USER&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'touch ~/.ssh/authorized_keys'&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /root/.ssh/authorized_keys &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /home/&lt;span class="nv"&gt;$USER&lt;/span&gt;/.ssh/authorized_keys
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 /home/&lt;span class="nv"&gt;$USER&lt;/span&gt;/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 /home/&lt;span class="nv"&gt;$USER&lt;/span&gt;/.ssh/authorized_keys
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt; ALL=(ALL:ALL) NOPASSWD: ALL"&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; /etc/sudoers.d/&lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;span class="nb"&gt;chmod &lt;/span&gt;0440 /etc/sudoers.d/&lt;span class="nv"&gt;$USER&lt;/span&gt;
visudo &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/sudoers.d/&lt;span class="nv"&gt;$USER&lt;/span&gt;
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a home directory, sets bash as the login shell, prepares &lt;code&gt;~/.ssh&lt;/code&gt;, and installs an SSH key for the new user. We are recreating &lt;em&gt;root&lt;/em&gt; as a different user in a way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Sometimes paths and locations may vary from system to system or provider to provider. You might need to replace the paths in that case.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;code&gt;NOPASSWD:&lt;/code&gt; settings let our user to run privileged commands without any additional password. You can set a password here, but it can make it difficult for running scripts. The &lt;code&gt;visudo -c&lt;/code&gt; check is important because it validates the sudoers file before you rely on it. A broken sudoers file can leave you unable to use sudo.&lt;/p&gt;

&lt;p&gt;Although not completely necessary (a lot of system admins keep the &lt;em&gt;root&lt;/em&gt; login today depending on all the other protections), adding a non-root user can make your configuration quite a bit more secure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disabling root access&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once your non-root user works, you can close the login access for &lt;em&gt;root&lt;/em&gt;. Similarly to previous SSH settings we can either edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; or drop a new config file to &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/ssh/sshd_config.d/92-disable-root-login.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
PermitRootLogin no
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;sshd &lt;span class="nt"&gt;-t&lt;/span&gt;
systemctl restart ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The settings we need this time is &lt;code&gt;PermitRootLogin no&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Swap
&lt;/h3&gt;

&lt;p&gt;Small VPS plans often come with 1GB or 2GB of RAM. That may be enough most of the time, but a web server, database, background jobs, deployments, and package upgrades can occasionally push memory usage higher than expected. If you use all the available RAM, processes start to crash.&lt;/p&gt;

&lt;p&gt;Swap space lets the Linux kernel move less-used memory pages from RAM to disk so active processes can keep running. Accessing disk space is way slower than RAM, but it can prevent an out-of-memory crash in a traffic spike.&lt;/p&gt;

&lt;p&gt;To allocate swap space we'll use the &lt;strong&gt;fallocate&lt;/strong&gt; program, then make it the swap space with &lt;strong&gt;mkswap&lt;/strong&gt; and &lt;strong&gt;swapon&lt;/strong&gt;. Last, we can configure the &lt;em&gt;swappiness&lt;/em&gt; parameter in /etc/sysctl.conf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 2GB /swapfile
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 /swapfile
mkswap /swapfile
swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n/swapfile swap swap defaults 0 0&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/fstab
sysctl vm.swappiness&lt;span class="o"&gt;=&lt;/span&gt;20&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;nvm.swappiness=20&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/sysctl.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;vm.swappiness=20&lt;/code&gt; setting tells the kernel to prefer RAM and use swap only when needed. A value around &lt;code&gt;10&lt;/code&gt; to &lt;code&gt;20&lt;/code&gt; is a good starting point for a VPS. The &lt;code&gt;/etc/fstab&lt;/code&gt; line makes the swapfile survive reboots. Without it, swap would work only until the next restart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker
&lt;/h3&gt;

&lt;p&gt;Now if you are going to deploy things with Docker, be it using Docker Compose, Coolify, Kamal, or anything else, you might preinstall Docker in this provisioning step as well. Some tools, like Kamal, can install Docker automatically, but it might be useful to preinstall it the way you want.&lt;/p&gt;

&lt;p&gt;You have several options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu's &lt;em&gt;docker.io&lt;/em&gt; system package&lt;/li&gt;
&lt;li&gt;The Docker Snap package&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/engine/install/ubuntu/" rel="noopener noreferrer"&gt;Docker's official &lt;em&gt;docker-ce&lt;/em&gt; packages&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They differ in their update policies and Snap also differ in ways it works (which is the reason it's often not compatible with other software like Kamal).&lt;/p&gt;

&lt;p&gt;To install it, just add another &lt;code&gt;apt install&lt;/code&gt; command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are using a non-root user for your operations, you might want to add it to a docker group which will let it run privileged Docker commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="o"&gt;[&lt;/span&gt;USERNAME]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A full example
&lt;/h2&gt;

&lt;p&gt;Now let's see how such a cloud-init script can look like in full with most of the options from this article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="c"&gt;# Provide these variables to use as a standalone script&lt;/span&gt;
&lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Configuring the system &lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt; for user &lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Do a system update&lt;/span&gt;
apt update&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;DEBIAN_FRONTEND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;noninteractive apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# Install essential packages&lt;/span&gt;
apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io curl unattended-upgrades fail2ban

&lt;span class="c"&gt;# Set up unattented updates&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"APT::Periodic::Update-Package-Lists &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;APT::Periodic::Unattended-Upgrade &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/apt/apt.conf.d/20auto-upgrades
/etc/init.d/unattended-upgrades restart

&lt;span class="c"&gt;# Install fail2ban&lt;/span&gt;
systemctl start fail2ban.service
systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;fail2ban.service

&lt;span class="c"&gt;# Configure firewall&lt;/span&gt;
ufw logging on&lt;span class="p"&gt;;&lt;/span&gt;
ufw default deny incoming&lt;span class="p"&gt;;&lt;/span&gt;
ufw default allow outgoing&lt;span class="p"&gt;;&lt;/span&gt;

ufw allow 80&lt;span class="p"&gt;;&lt;/span&gt;
ufw allow 443&lt;span class="p"&gt;;&lt;/span&gt;
ufw allow 22&lt;span class="p"&gt;;&lt;/span&gt;

ufw allow from 10.0.0.0/16 to any port 3000&lt;span class="p"&gt;;&lt;/span&gt;
ufw allow from 10.0.0.0/16 to any port 3100&lt;span class="p"&gt;;&lt;/span&gt;
ufw allow from 10.0.0.0/16 to any port 3306&lt;span class="p"&gt;;&lt;/span&gt;
ufw allow from 10.0.0.0/16 to any port 5432&lt;span class="p"&gt;;&lt;/span&gt;
ufw allow from 10.0.0.0/16 to any port 6379&lt;span class="p"&gt;;&lt;/span&gt;
ufw allow from 10.0.0.0/16 to any port 9090&lt;span class="p"&gt;;&lt;/span&gt;
ufw allow from 10.0.0.0/16 to any port 9100&lt;span class="p"&gt;;&lt;/span&gt;

ufw &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nb"&gt;enable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
systemctl restart ufw.service

&lt;span class="c"&gt;# Add swap space&lt;/span&gt;
fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 2GB /swapfile
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 /swapfile
mkswap /swapfile
swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n/swapfile swap swap defaults 0 0&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/fstab
sysctl vm.swappiness&lt;span class="o"&gt;=&lt;/span&gt;20&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;nvm.swappiness=20&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/sysctl.conf

&lt;span class="c"&gt;# Add non-root user&lt;/span&gt;
useradd &lt;span class="nt"&gt;--create-home&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt;
usermod &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash &lt;span class="nv"&gt;$USER&lt;/span&gt;
su - &lt;span class="nv"&gt;$USER&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'mkdir -p ~/.ssh'&lt;/span&gt;
su - &lt;span class="nv"&gt;$USER&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'touch ~/.ssh/authorized_keys'&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /root/.ssh/authorized_keys &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /home/&lt;span class="nv"&gt;$USER&lt;/span&gt;/.ssh/authorized_keys
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 /home/&lt;span class="nv"&gt;$USER&lt;/span&gt;/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 /home/&lt;span class="nv"&gt;$USER&lt;/span&gt;/.ssh/authorized_keys
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt; ALL=(ALL:ALL) NOPASSWD: ALL"&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; /etc/sudoers.d/&lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;span class="nb"&gt;chmod &lt;/span&gt;0440 /etc/sudoers.d/&lt;span class="nv"&gt;$USER&lt;/span&gt;
visudo &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/sudoers.d/&lt;span class="nv"&gt;$USER&lt;/span&gt;
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;

&lt;span class="c"&gt;# Disable root access&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s@PasswordAuthentication yes@PasswordAuthentication no@g'&lt;/span&gt; /etc/ssh/sshd_config
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s@PermitRootLogin yes@PermitRootLogin no@g'&lt;/span&gt; /etc/ssh/sshd_config

systemctl restart ssh.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After your system is fully provisioned, make sure to update the cloud's firewall and optionally configure some system monitoring. Some providers come with built-in monitoring that will work automatically if you opt-in into installing their package during a setup step.&lt;/p&gt;

&lt;p&gt;You might also want to enable full virtual server backups as most providers support them. They will snapshot the entire virtual server which can come in handy when things go wrong (note they usually cost extra).&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;If your custom configuration didn't work for some unknown reason and you need to debug what went wrong with your script, log in to the machine using your SSH key and have a look at cloud-init log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; /var/log/cloud-init-output.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They are also &lt;a href="https://docs.cloud-init.io/en/23.4/howto/locate_files.html" rel="noopener noreferrer"&gt;other locations&lt;/a&gt; to check.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Note that it's better not to close access for &lt;em&gt;root&lt;/em&gt; before you are confident that your configuration will work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;Once you have your server up and running, you can continue to improve the configuration, install a PaaS like Coofily, or start self-hosting with Kamal.&lt;/p&gt;

&lt;p&gt;Have a look at some of our other posts on self hosting to get you started with that:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fstorage.ghost.io%2Fc%2Fa5%2F00%2Fa5004977-0dd2-4bcd-9292-dd0e05d4c59e%2Fcontent%2Fimages%2Fthumbnail%2FScreenshot-2026-03-31-at-10.19.30-1.png" width="800" height="417"&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://serpapi.com/blog/how-to-start-self-hosting-with-coolify-4-vps/" rel="noopener noreferrer"&gt;How to start self-hosting with Coolify 4 on a VPS&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fstorage.ghost.io%2Fc%2Fa5%2F00%2Fa5004977-0dd2-4bcd-9292-dd0e05d4c59e%2Fcontent%2Fimages%2Fthumbnail%2FScreenshot-2026-03-31-at-11.37.53.png" width="800" height="419"&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://serpapi.com/blog/self-host-serpbear-coolify/" rel="noopener noreferrer"&gt;Self-host SerpBear with Coolify&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;&lt;/table&gt;&lt;/div&gt;

</description>
      <category>selfhosting</category>
    </item>
    <item>
      <title>How to implement AI agents in Rails with RubyLLM</title>
      <dc:creator>Josef Strzibny</dc:creator>
      <pubDate>Fri, 24 Apr 2026 07:41:13 +0000</pubDate>
      <link>https://dev.to/serpapi/how-to-implement-ai-agents-in-rails-with-rubyllm-4fh7</link>
      <guid>https://dev.to/serpapi/how-to-implement-ai-agents-in-rails-with-rubyllm-4fh7</guid>
      <description>&lt;p&gt;Chat-based agents are augmented LLM interfaces with access to a list of predefined tools. RubyLLM Agents are reusable AI assistants implemented as models with their configuration, runtime context, and prompt conventions. Let's see how we can start implementing custom OpenAI chat agents with access to SERP tools with the help of the RubyLLM gem.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Note the difference between fully autonomous agents like Claude Code or Codex, and chat-based agents that still react to user input. This post is about the latter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Simple chats vs agents
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://rubyllm.com" rel="noopener noreferrer"&gt;RubyLLM&lt;/a&gt; is a Ruby gem and an AI interface for GPT, Claude, and Gemini to give us an easy way to run LLM chat inside a Ruby application. It allows us to avoid writing JSON and lets us work with AI using beautiful Ruby DSL.&lt;/p&gt;

&lt;p&gt;A regular RubyLLM chat is a conversation. A user sends a message, the model responds, and the exchange continues back and forth. It works but it's limited to what the model can do. Today's models can do way more than before as they often search the web and find up-to-date information. However, they still do a lot of guessing and cannot access your internal data. This means we need to be very specific and provide a lot of context for the LLM to understand our request.&lt;/p&gt;

&lt;p&gt;Imagine we ask an LLM something more complex with a simple sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Choose any model you like, just make sure to set up&lt;/span&gt;
&lt;span class="c1"&gt;# the access token in the initializator&lt;/span&gt;
&lt;span class="n"&gt;chat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"gpt-5.4-mini"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ask&lt;/span&gt; &lt;span class="s2"&gt;"Is our 'Aeropress Coffee Maker' at $39.99 competitive?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How could this LLM model know the answer?&lt;/p&gt;

&lt;p&gt;Perhaps the agent could search our pages as well as our competitors' to gain a full understanding of our request. This usually requires some back and forth to ensure the LLM has all the context needed to give an accurate response. If we want more precision, access to application data, and better results, we need to give the chat agent access to the tools we control.&lt;/p&gt;

&lt;p&gt;Now imagine what would happen if the chat agent has access to a local database and Google Shopping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;chat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"gpt-5.4-mini"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_tools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LookupProduct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SearchGoogleShopping&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ask&lt;/span&gt; &lt;span class="s2"&gt;"Is our 'Aeropress Coffee Maker' competitively priced?"&lt;/span&gt;

&lt;span class="c1"&gt;# =&amp;gt; "Your Aeropress Coffee Maker is listed at $39.99. Here are current&lt;/span&gt;
&lt;span class="c1"&gt;# Google Shopping prices:&lt;/span&gt;
&lt;span class="c1"&gt;# 1. Amazon — $34.95&lt;/span&gt;
&lt;span class="c1"&gt;# 2. Target — $37.99&lt;/span&gt;
&lt;span class="c1"&gt;# 3. Walmart — $33.49&lt;/span&gt;
&lt;span class="c1"&gt;# 4. Williams Sonoma — $41.95&lt;/span&gt;
&lt;span class="c1"&gt;# 5. REI — $39.95&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# You're on the higher end. Three out of five retailers are under $38.&lt;/span&gt;
&lt;span class="c1"&gt;# Consider adjusting to ~$36-37 to stay competitive."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suddenly it has everything it needs to answer such an ambiguous question. It can search for competitors products on &lt;a href="https://serpapi.com/google-shopping-api" rel="noopener noreferrer"&gt;Google Shopping&lt;/a&gt; and compare it with data we have in our product catalog. Not bad at all.&lt;/p&gt;

&lt;p&gt;The concept of using tools is simple. We describe a set of tools to the model, each with a name, parameters, and what it does. When the model determines it needs specific information or wants to perform an action, it returns a structured tool call&lt;br&gt;&lt;br&gt;
instead of plain text which looks like JSON below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_calls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"call_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lookup_product"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Aeropress Coffee Maker&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our code now needs to execute this function call and feed the result back which is all handled by RubyLLM in the background. The model then continues reasoning with the new information.&lt;/p&gt;

&lt;p&gt;This loop which &lt;em&gt;reasons, acts, and observes&lt;/em&gt; is what turns a language model into something that can actually get work done. And even better, we can wrap it all in a reusable agent class thanks to the RubyLLM Agents support. But first, let's implement the tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools
&lt;/h2&gt;

&lt;p&gt;RubyLLM tools are interfaces for runnable code we control in an LLM chat. Both &lt;code&gt;LookupProduct&lt;/code&gt; and &lt;code&gt;SearchGoogleShopping&lt;/code&gt; tools would be implemented as Ruby classes inherited from &lt;code&gt;RubyLLM::Tool&lt;/code&gt;. We name them using &lt;code&gt;description&lt;/code&gt;, provide a set of acceptable params using &lt;code&gt;param&lt;/code&gt;, and implement the &lt;code&gt;execute&lt;/code&gt; method.&lt;/p&gt;

&lt;p&gt;Here's an example of how this could look when searching a local product catalog for &lt;code&gt;LookupProduct&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LookupProduct&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Tool&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="s2"&gt;"Looks up a product in our catalog by name"&lt;/span&gt;
  &lt;span class="n"&gt;param&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;desc: &lt;/span&gt;&lt;span class="s2"&gt;"Product name to search for"&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
     &lt;span class="no"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"name ILIKE ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's the code for &lt;code&gt;SearchGoogleShopping&lt;/code&gt; that works with Google Shopping using &lt;a href="https://serpapi.com" rel="noopener noreferrer"&gt;SerpApi&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SearchGoogleShopping&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Tool&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="s2"&gt;"Searches Google Shopping for current market prices of a product"&lt;/span&gt;
  &lt;span class="n"&gt;param&lt;/span&gt; &lt;span class="ss"&gt;:query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;desc: &lt;/span&gt;&lt;span class="s2"&gt;"The product to search for"&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SerpApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;q: &lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;engine: &lt;/span&gt;&lt;span class="s2"&gt;"google_shopping"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shopping_results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;title: &lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;price: &lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:price&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;source: &lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:source&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see that RubyLLM does all the heavy lifting, allowing us to write code as usual.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents
&lt;/h2&gt;

&lt;p&gt;Once we have our tools ready, we can introduce them to the chat using the &lt;code&gt;chat.with_tools&lt;/code&gt; call. We can also go one step further and wrap this up as a reusable class in an agent thanks to the &lt;code&gt;RubyLLM::Agent&lt;/code&gt; interface.&lt;/p&gt;

&lt;p&gt;An agent in this context is a class that couples instructions with a set of tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PriceMonitorAgent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Agent&lt;/span&gt;
  &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="s2"&gt;"gpt-5.4-mini"&lt;/span&gt;

  &lt;span class="n"&gt;instructions&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;~&lt;/span&gt;&lt;span class="no"&gt;PROMPT&lt;/span&gt;&lt;span class="sh"&gt;
      You are a pricing analyst. You help the merchandising team keep our product
      catalog competitively priced. You can look up our products, check current
      Google Shopping prices, and find products where we are significantly overpriced.
      Always show specific numbers when comparing prices.
&lt;/span&gt;&lt;span class="no"&gt;    PROMPT&lt;/span&gt;

  &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="no"&gt;LookupProduct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SearchGoogleShopping&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FindUndercut&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;PriceMonitorAgent&lt;/code&gt; like the one above could help the shop's merchandising team find products that are overpriced on the current market:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;PriceMonitorAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ask&lt;/span&gt; &lt;span class="s2"&gt;"Which of our coffee products are priced more than 15% above market?"&lt;/span&gt;

&lt;span class="c1"&gt;# =&amp;gt; "I found 3 coffee products above the 15% threshold:&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# 1. Baratza Encore Grinder — Our price: $179.99, Market avg: $149.80 (20.2% over)&lt;/span&gt;
&lt;span class="c1"&gt;# 2. Fellow Stagg Kettle — Our price: $94.99, Market avg: $79.60 (19.3% over)&lt;/span&gt;
&lt;span class="c1"&gt;# 3. Chemex 6-Cup — Our price: $54.99, Market avg: $44.97 (22.3% over)&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# The Aeropress ($39.99 vs $37.48 avg) and Hario V60 ($11.99 vs $11.20 avg)&lt;/span&gt;
&lt;span class="c1"&gt;# are within range and look fine."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depending on the question, the agent can pick one tool to generate a response or use an additional tool from the list to find the right answer. Combining internal data with live SERP data is incredibly powerful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging
&lt;/h2&gt;

&lt;p&gt;Sometimes we might not be sure if the agents are using our tools in the way we expected. Luckily, chats in RubyLLM let us see all the tool calls that were done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool_calls&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inspect&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we want to specifically recheck what search queries were run using SerpApi, we can head to &lt;a href="https://serpapi.com/searches" rel="noopener noreferrer"&gt;serpapi.com/searches&lt;/a&gt; after logging in, and find the searches that were done. We'll get a full &lt;strong&gt;Search Inspector&lt;/strong&gt; including the returned page and JSON:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4hadtmk1ypuyx89n03ec.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4hadtmk1ypuyx89n03ec.png" alt="SerpApi Search Inspector" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;SerpApi makes it super easy to integrate search data from &lt;a href="https://serpapi.com/search-api" rel="noopener noreferrer"&gt;Google&lt;/a&gt;, &lt;a href="https://serpapi.com/amazon-product-api" rel="noopener noreferrer"&gt;Amazon&lt;/a&gt;, &lt;a href="https://serpapi.com/bing-search-api" rel="noopener noreferrer"&gt;Bing&lt;/a&gt;, and other search engines into your application. And RubyLLM makes it easy to expose this API as a tool for your agents. Give SerpApi a try with 250 free searches/month.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>Self-host SerpBear with Coolify</title>
      <dc:creator>Josef Strzibny</dc:creator>
      <pubDate>Thu, 02 Apr 2026 08:47:23 +0000</pubDate>
      <link>https://dev.to/serpapi/self-host-serpbear-with-coolify-569h</link>
      <guid>https://dev.to/serpapi/self-host-serpbear-with-coolify-569h</guid>
      <description>&lt;p&gt;Tracking organic positions on search engines is one of the main concerns of &lt;a href="https://serpapi.com/use-cases/seo" rel="noopener noreferrer"&gt;SEO&lt;/a&gt;. SerpBear is a SERP position tracking tool that will help you track how well you are doing in Google search. This post will take you through all the steps to start tracking your keywords with your own SerpBear instance on Coolify.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is SerpBear?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdn02fo1req3grn26po8q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdn02fo1req3grn26po8q.png" alt="SerpBear logo" width="319" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/towfiqi/serpbear" rel="noopener noreferrer"&gt;SerpBear&lt;/a&gt; is an Open Source &lt;strong&gt;search engine position tracker&lt;/strong&gt;. It allows you to track your or your competitors' keyword positions in Google and to also get notified of these changes. Some of the features include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unlimited keywords:&lt;/strong&gt;  You can add as many domains and keywords to track as you want. SerpBear will use a 3rd-party API such as SerpApi for actual tracking behind the scenes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexible scraping strategy:&lt;/strong&gt;  Choose your scraping strategy to control how many Google pages are checked for each domain. It's built to handle the Google's 2025 removal of the &lt;code&gt;num=100&lt;/code&gt;parameter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email notification:&lt;/strong&gt;  Get notified of all your keyword position changes via email with at the frequency of your choosing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyword research:&lt;/strong&gt;  You have the option to do keyword research with the integration of Google Ads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Search Console:&lt;/strong&gt;  You can see the actual visit count and impressions for each keyword. Discover new keywords thanks to direct integration with Google Search Console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exports:&lt;/strong&gt;  Export your domain keyword data in CSV files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to track your keywords, but don't want to use SerpBear, have a look how to do your custom &lt;a href="https://serpapi.com/blog/serp-tracking-api-create-a-whiltelabel-rank-tracker-app/" rel="noopener noreferrer"&gt;SERP tracking in JavaScript&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Coolify?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://coolify.io" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt; is an Open Source &lt;strong&gt;Platform as a Service&lt;/strong&gt; (PaaS) that will help you self-host SerpBear or any other self-hostable software out there. You can also deploy applications, databases, and one-click services.&lt;/p&gt;

&lt;p&gt;I am assuming you already have Coolify installed on your server. If you don't, you can learn how to &lt;a href="https://serpapi.com/blog/how-to-start-self-hosting-with-coolify-4-vps/" rel="noopener noreferrer"&gt;deploy Coolify on any VPS&lt;/a&gt; or take advantage of Coolify Cloud version where you don't have to host Coolify yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 We are going to deploy SerpBear 3.0 on Coolify 4.0. Coolify 4.0 is still beta software for the time being.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Signing up for SerpApi
&lt;/h2&gt;

&lt;p&gt;SerpApi is a service providing access to various SERP results with a simple API. SerpBear is free and open source software, but it uses SerpApi to do the heavy lifting of querying and parsing Google results. SerpBear cannot do this on its own due to the nature of managing proxies and solving CAPTCHAs, but you can still start with free searches to set everything up and see it working before committing to a paid plan.&lt;/p&gt;

&lt;p&gt;So first of all, &lt;a href="https://serpapi.com/users/sign_up" rel="noopener noreferrer"&gt;sign up for SerpApi&lt;/a&gt; and then note your private API key from &lt;a href="https://serpapi.com/manage-api-key" rel="noopener noreferrer"&gt;serpapi.com/manage-api-key&lt;/a&gt; page:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7msjg3kgrsq42hzuuj5v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7msjg3kgrsq42hzuuj5v.png" alt="SerpApi dashboard" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We are going to use this API key from SerpBear, but you can also use it to integrating one the many SerpApi SDKs to build anything needing &lt;a href="https://serpapi.com" rel="noopener noreferrer"&gt;SERP API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring DNS
&lt;/h2&gt;

&lt;p&gt;Head over to your domain registrar console and set the DNS A records for domains or subdomains you want to use for SerpBear:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;coolify.example.com -&amp;gt; 178.104.25.112
serpbear.example.com -&amp;gt; 178.104.25.112
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Choose "A" type records to directly connect a domain name or subdomain to the IP address (replace the values, the above is just an example). If you'll run SerpBear on the same host as Coolify, the IP address remains the same for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding SerpBear
&lt;/h2&gt;

&lt;p&gt;If your Coolify instance is fully set up, it's time to add SerpBear. To run a new Coolify project head over to &lt;em&gt;Projects&lt;/em&gt; (from the left menu) and click on &lt;strong&gt;+ Add&lt;/strong&gt; next to the Projects headline. Give it a name and description.&lt;/p&gt;

&lt;p&gt;From Projects, click on &lt;strong&gt;+ Add Resource&lt;/strong&gt; next to your project name or select your project first and click on &lt;strong&gt;+ New&lt;/strong&gt; next to &lt;em&gt;Resources&lt;/em&gt;. From here, we can select &lt;strong&gt;Docker Compose Empty&lt;/strong&gt; under &lt;em&gt;Docker Based&lt;/em&gt; on the right:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fimetq2p3oc3qu5bp4if5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fimetq2p3oc3qu5bp4if5.png" alt="Coolify's New Resource page" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SerpBear provides Docker Compose configuration which Coolify supports. Since SerpBear uses SQLite to save SERP data, it's not necessary to spin up process-based databases and the Compose file is relatively simple:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;services:
  app:
    image: towfiqi/serpbear:latest
    # Build from source instead of pulling the image:
    # build: .
    restart: unless-stopped
    ports:
      - "${PORT:-3000}:3000"
    environment:
      - USER_NAME=${USER:-admin}
      - PASSWORD=${PASSWORD}
      - SECRET=${SECRET}
      - APIKEY=${APIKEY}
      - SESSION_DURATION=${SESSION_DURATION:-24}
      - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
      # Optional: Google Search Console integration
      - SEARCH_CONSOLE_CLIENT_EMAIL=${SEARCH_CONSOLE_CLIENT_EMAIL:-}
      - SEARCH_CONSOLE_PRIVATE_KEY=${SEARCH_CONSOLE_PRIVATE_KEY:-}
    volumes:
      - serpbear_data:/app/data
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3000 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s

volumes:
  serpbear_data:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The SerpBear service definition includes the official Docker image, container restart policy, ports, environment variables, volumes, and health check. The official guide suggests using the latest image (&lt;code&gt;towfiqi/serpbear:latest&lt;/code&gt;) which will let you update the application with &lt;strong&gt;Redeploy&lt;/strong&gt; at any later point, but you can choose a stable release to lock-in a particular version.&lt;/p&gt;

&lt;p&gt;The required environment variables reference variables like &lt;code&gt;{USER:-admin}&lt;/code&gt; and &lt;code&gt;{SECRET}&lt;/code&gt; which let us manage the environment directly from the Coolify admin interface. Notice that SerpBear also supports Google Search Console, so if you can obtain these credentials as well. Finally notice the &lt;code&gt;serpbear_data&lt;/code&gt; volume which is the future location of your SerpBear database.&lt;/p&gt;

&lt;p&gt;Paste the configuration above. Click &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Adding the new resource should take you to the resource configuration. Under &lt;em&gt;General&lt;/em&gt; we can change the resource name, under &lt;em&gt;Persistent Storages&lt;/em&gt; we can check our persistent volume, and finally under &lt;em&gt;Environment Variables&lt;/em&gt; we can go fill up the variables from the Compose file:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4jygvw1jk4gh62y1hksp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4jygvw1jk4gh62y1hksp.png" alt="Coolify configuration" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the list of environment variables we should fill in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;USER_NAME&lt;/code&gt;: The username you want to use to login to the app.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PASSWORD&lt;/code&gt;: The password you want to use to log in to the app.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SECRET&lt;/code&gt;: A secret key that will be used for encrypting 3rd party API keys &amp;amp; passwords.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;APIKEY&lt;/code&gt;: API key that will be used to access the app's API. This is not SerpApi account key!&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SESSION_DURATION&lt;/code&gt;: The duration(in hours) of the user's logged-in session.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_APP_URL&lt;/code&gt;: The URL where your app is hosted and can be accessed like &lt;a href="https://serpbear.example.com" rel="noopener noreferrer"&gt;https://serpbear.example.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note that every environment entry has its own &lt;strong&gt;Update&lt;/strong&gt; button and they won't be saved all at once. You can add the Google Search Console credentials if you have them, but they aren't necessary to start tracking your keywords with SerpApi.&lt;/p&gt;

&lt;p&gt;Restart the service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracking keywords with SerpBear
&lt;/h2&gt;

&lt;p&gt;If you carefully followed all the steps, you should be now able to access the SerpBear instance on the (sub)domain of your choosing. Log in using the chosen credentials from the previous step. You should see a red prompt at the top to configure your &lt;em&gt;Scrapper/Proxy&lt;/em&gt;. Click on it to open settings, choose &lt;em&gt;SerpApi.com&lt;/em&gt; as the &lt;em&gt;Scraping Method&lt;/em&gt; and insert your SerpApi API token under &lt;em&gt;Scraper API Key Or Token&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fixend0ftf2mskym40yxi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fixend0ftf2mskym40yxi.png" alt="SerpBear settings" width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Update Settings&lt;/strong&gt; to close the right sidebar and you'll be ready to add your first keywords to track. Start by adding a domain name. After entering your domain name you should end up on the &lt;em&gt;Tracking&lt;/em&gt; tab for the domain.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Add Keyword&lt;/strong&gt; and start adding keywords and phrases you care about. They should appear in a nice table with their last position, best position, history graph, volume, and URL. Here's an example of tracking "serp api" for serpapi.com in the Czech Republic:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frm9j1lmysprto8m5z0pi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frm9j1lmysprto8m5z0pi.png" alt="SerpBear keywords" width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And that's it! You can now add as many domains and keywords as you want to. Remember that SEO today is not just about tracking organic results in Google, so have a look at &lt;a href="https://serpapi.com/blog/rank-tracking-in-the-age-of-ai-overviews-whats-changed/" rel="noopener noreferrer"&gt;AI overviews&lt;/a&gt; as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;As next steps you can consider adding your Google Search Console data to SerpBear, handle external backups in Coolify, or exploring more of &lt;a href="https://serpapi.com" rel="noopener noreferrer"&gt;SerpApi&lt;/a&gt;. The Free tier comes with &lt;strong&gt;250 free searches&lt;/strong&gt; per month.&lt;/p&gt;

</description>
      <category>selfhosting</category>
    </item>
    <item>
      <title>How to start self-hosting with Coolify 4 on a VPS</title>
      <dc:creator>Josef Strzibny</dc:creator>
      <pubDate>Wed, 01 Apr 2026 11:57:51 +0000</pubDate>
      <link>https://dev.to/serpapi/how-to-start-self-hosting-with-coolify-4-on-a-vps-44ob</link>
      <guid>https://dev.to/serpapi/how-to-start-self-hosting-with-coolify-4-on-a-vps-44ob</guid>
      <description>&lt;p&gt;Coolify is an open source Platform as a Service (PaaS) that can help you self-host a lot of different software from content management tools like Ghost to SEO tracking software like SerpBear. The only thing you'll need is a virtual private server (VPS) from a hosting provider of your choice and a little bit of set up which is exactly what we'll go through in this post.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡&lt;/p&gt;

&lt;p&gt;Coolify v4 is still beta software as of the time of writing of this post. If you need a little bit more stability, wait for the final release.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is Coolify?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvv4bflbq7tzhjagp3lv0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvv4bflbq7tzhjagp3lv0.png" alt="Coolify logo" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/coollabsio/coolify" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt; is a self-hostable alternative to Heroku, Vercel, or Netlify. It's an open source PaaS that allows developers to deploy their applications as well as manage 3rd-party services and databases.&lt;/p&gt;

&lt;p&gt;Some of the biggest advantages include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted control:&lt;/strong&gt;  You own your data on your own servers which saves money and avoids vendor lock-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting versatility:&lt;/strong&gt;  Hosts web applications, static websites, databases (PostgreSQL, MySQL, MongoDB, etc.), and services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated DevOps:&lt;/strong&gt;  Automatically installs dependencies, sets up databases, and handles deployments from GitHub, GitLab, or Bitbucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web console:&lt;/strong&gt;  Offers a web-based dashboard for managing multiple servers, viewing logs, and monitoring resource usage like CPU and RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in tools:&lt;/strong&gt;  Features automatic S3-compatible backups, auto provisioning of SSL certificates, webhooks, or preview deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can run Coolify in various ways. You can use a single server for everything you host or do a separate deployment for the services you'll run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Applications, databases and services
&lt;/h3&gt;

&lt;p&gt;Coolify supports deploying custom applications and 3rd-party services. It generally distinguish between 3 different types of resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Applications&lt;/strong&gt; come with &lt;strong&gt;a git source&lt;/strong&gt; or are built using Docker from &lt;strong&gt;Dockerfile&lt;/strong&gt;. You can deploy anything from &lt;a href="https://serpapi.com/integrations/php" rel="noopener noreferrer"&gt;PHP&lt;/a&gt; to &lt;a href="https://serpapi.com/integrations/java" rel="noopener noreferrer"&gt;Java&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Databases&lt;/strong&gt; are preconfigured Docker images for running databases, like MySQL or PostgreSQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Services&lt;/strong&gt; are deployments based on  &lt;strong&gt;Docker Compose&lt;/strong&gt; files that are stored directly on the server. You deploy well known software like Ghost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can mix and match any of these on a single Coolify instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the virtual server
&lt;/h2&gt;

&lt;p&gt;There are two variants to self-hosting. One with physical servers that are usually dedicated to you and one with virtual servers where you share the underlying physical servers with others.&lt;/p&gt;

&lt;p&gt;We'll set up a simple virtual server on Hetzner which is a provider praised for its affordable costs. You are free to use any other provider you like or already have. Some other popular providers include Digital Ocean, Vultr, or OVHcloud.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡&lt;/p&gt;

&lt;p&gt;Coolify currently offers $20 credit for deploying to Hetzner by following the &lt;a href="https://coolify.io/hetzner" rel="noopener noreferrer"&gt;https://coolify.io/hetzner&lt;/a&gt; link.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To create a virtual private server (VPS), you'll need an SSH key pair which you'll authenticate with in the future. You can optionally set a password for the private key as well. To generate a new one, run &lt;code&gt;ssh-keygen&lt;/code&gt;:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh-keygen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The command will interactively ask you about your key details. If you name it &lt;em&gt;coolify&lt;/em&gt; you should end up with two files inside the &lt;code&gt;~/.ssh&lt;/code&gt; directory, one for private key and one for public key (&lt;code&gt;coolify.pub&lt;/code&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡&lt;/p&gt;

&lt;p&gt;Protect your SSH private key! You always provide others with your public key, not the private key. The private key should stay with you on your computer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once you have your key pair, it's fairly straightforward to spin up a new server with the provider of your choice.&lt;/p&gt;

&lt;p&gt;On Hetzner, create a new project and on the project overview click &lt;strong&gt;Add Server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffgr3ye1zo0zmrcobmmg6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffgr3ye1zo0zmrcobmmg6.png" alt="Creating a server on Hetzner" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Select a location close to you, Ubuntu 24.04 or your favorite operating system (Debian, SUSE, and Fedora based systems are supported), the size you want, and optionally enable full virtual machine backups. Under &lt;em&gt;SSH Keys&lt;/em&gt; upload your &lt;strong&gt;public SSH key&lt;/strong&gt; which should disable password-based access on most providers and let you use your private key instead.&lt;/p&gt;

&lt;p&gt;As for the size of the box, Coolify recommends at least 2 vCPUs, 2 GB of RAM, and 30 GB of storage space. If you are going to self-host projects on the same instance, you generally need to increase the box size accordingly. For only running one or two extra applications, consider increasing RAM.&lt;/p&gt;

&lt;p&gt;This is all that's needed for things to work, but we can also provide a custom cloud-init script under &lt;em&gt;Cloud config&lt;/em&gt;. For example, we can set up automatic system updates and install fail2ban for extra protection of our SSH port:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Do a system update
apt update;
DEBIAN_FRONTEND=noninteractive apt upgrade -y

# Install essential packages
apt install -y curl unattended-upgrades fail2ban

# Set up unattented updates
echo -e "APT::Periodic::Update-Package-Lists \"1\";\nAPT::Periodic::Unattended-Upgrade \"1\";\n" &amp;gt; /etc/apt/apt.conf.d/20auto-upgrades
/etc/init.d/unattended-upgrades restart

# Install fail2ban
systemctl start fail2ban.service
systemctl enable fail2ban.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;You can optionally install Docker at this step from the system package manager but &lt;strong&gt;do not&lt;/strong&gt; install Docker from Snap as that's not supported by Coolify.&lt;/p&gt;

&lt;p&gt;Finally, give the server a name (you can call it &lt;em&gt;coolify&lt;/em&gt;, but do not use a domain name) and click &lt;strong&gt;Create &amp;amp; Buy now&lt;/strong&gt;. Provisioning the server will take some minutes and once done you should be able to find the public server IPv4 address next to its name on the server page:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgwrli5gt5me89w7jtem5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgwrli5gt5me89w7jtem5.png" alt="Server details on Hetzner" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You should now be able to log in from your terminal as:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh-add ~/.ssh/path-to-your-key
ssh root@[IP_ADDRESS]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;There is quite a bit more to running and securing servers. You should ideally choose a different user than &lt;em&gt;root&lt;/em&gt; and/or disable public SSH access altogether in the cloud's firewall. If you want to do that, have a look at WireGuard or Tailscale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring DNS
&lt;/h2&gt;

&lt;p&gt;Now that you have a public IP address, you can create DNS records to get nice URLs for accessing Coolify, SerpBear, and whatever else you decide to self host.&lt;/p&gt;

&lt;p&gt;Head over to your domain registrar console and set the DNS A records for domains or subdomains you want to use for Coolify and the applications you want to host:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# For accesing Coolify web console (admin area)
coolify.example.com -&amp;gt; 178.104.25.112

# Example application
serpbear.example.com -&amp;gt; 178.104.25.112
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Choose "A" type records to directly connect a domain name or subdomain to the IP address (replace the values, the above is just an example). You can use Porkbun or GoDaddy to register your first domain name if you don't have one yet.&lt;/p&gt;

&lt;p&gt;Since we'll run everything on a single host, the IP address remains the same for all instances.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Coolify
&lt;/h2&gt;

&lt;p&gt;Now that we have our Linux box up and running, we can SSH into it and run the official installer script:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -fsSL https://cdn.coollabs.io/coolify/install.sh | sudo bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;You can change some installation details with environment variables, so go and &lt;a href="https://coolify.io/docs/get-started/installation#advanced-customizing-installation-with-environment-variables" rel="noopener noreferrer"&gt;review this list&lt;/a&gt; before running the script.&lt;/p&gt;

&lt;p&gt;For example, you might want to choose the admin username, email, and password:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;env ROOT_USERNAME=admin \
ROOT_USER_EMAIL=admin@example.com \
ROOT_USER_PASSWORD=SecurePassword123 \
bash -c 'curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Once run, you'll see a &lt;em&gt;Congratulations!&lt;/em&gt; screen with the list of the IP addresses, version as well as a log file:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;____ _ _ _ _ _
  / ___|___ _ ____ _ _ ____ _| |_ _ _| | ___| |_(_)___ _ _____ | |
 | | / _ \| '_ \ / _` | ' __/ _` |__ | | | | |/ _` | __| |/ _ \| '_ \/__ | |
 | |__| (_) | | | | (_| | | | (_| | |_| |_| | | (_| | |_| | (_) | | | \__ \_|
  \ ____\___ /|_| |_|\ __, |_| \__ ,_|\ __|\__ ,_|_|\ __,_|\__ |_|\ ___/|_| |_|___ (_)
                   |___/


Your instance is ready to use!

You can access Coolify through your Public IPV4: http://178.104.25.112:8000
You can access Coolify through your Public IPv6: http://[2a01:4f8:1c19:f81a::1]:8000

If your Public IP is not accessible, you can use the following Private IPs:

http://10.0.0.1:8000
http://10.0.1.1:8000
http://2a01:4f8:1c19:f81a::1:8000
http://fdb0:c330:3639::1:8000

WARNING: It is highly recommended to backup your Environment variables file (/data/coolify/source/.env) to a safe location, outside of this server (e.g. into a Password Manager).


============================================================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;[2026-03-19 11:02:44] Installation Complete&lt;br&gt;
    ============================================================&lt;br&gt;
[2026-03-19 11:02:44] Coolify installation completed successfully&lt;br&gt;
[2026-03-19 11:02:44] Version: 4.0.0-beta.468&lt;br&gt;
[2026-03-19 11:02:44] Log file: /data/coolify/source/installation-20260319-110120.log&lt;/p&gt;

&lt;p&gt;After installation, you should find your self-hosted Coolify instance at &lt;a href="http://203.0.113.1:8000" rel="noopener noreferrer"&gt;&lt;code&gt;http://[IP_ADDRESS]:8000&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fosc405o9brtdo9030uvq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fosc405o9brtdo9030uvq.png" alt="Coolify welcome page" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You should see a &lt;em&gt;Welcome to Coolify&lt;/em&gt; screen. Click &lt;strong&gt;Let's go!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6veytuocsa5v3x7zabat.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6veytuocsa5v3x7zabat.png" alt="Coolify sign up" width="800" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create your root account and on the next screen choose &lt;em&gt;This Machine&lt;/em&gt; as your server type (we'll run everything on a single instance):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ctgo1o5skz5ogwxie3m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ctgo1o5skz5ogwxie3m.png" alt="Coolify server type selection" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the initial wizard, let's go to &lt;em&gt;Settings&lt;/em&gt; (from the left menu) and input the instance URL with our (sub)domain we prepared for Coolify under &lt;em&gt;General&lt;/em&gt; (make sure to include full URL including the leading &lt;code&gt;https://&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv2un11h72yuweql3dscb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv2un11h72yuweql3dscb.png" alt="Coolify localhost instance settings" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Optionally you can also explore the &lt;em&gt;Backup&lt;/em&gt; and &lt;em&gt;Transactional Email&lt;/em&gt; tabs for setting up Coolify backups and transactional emails (for things like forgotten password).&lt;/p&gt;

&lt;p&gt;Then head over to &lt;em&gt;Servers&lt;/em&gt; (from the left menu) where you should see our &lt;em&gt;localhost&lt;/em&gt; instance (running Coolify). Here you'll find settings for the Coolify components. Open it and go to the &lt;em&gt;Proxy&lt;/em&gt; tab:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhgq3495fzrngc5j129jc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhgq3495fzrngc5j129jc.png" alt="Coolify proxy" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Checking that the proxy is running is important as it will route the traffic to the applications you'll self host. Even if Coolify is running and you are logged in inside the console, the &lt;em&gt;coolify-proxy&lt;/em&gt; might not be.&lt;/p&gt;

&lt;p&gt;In case the proxy is not running, open the logs and see what's wrong. In my case, the proxy wasn't running and I found the following inside the logs:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Creating required Docker Compose file.
Pulling docker image.
 Image traefik:v3.6 Pulling 
 Image traefik:v3.6 Pulled 
Ensuring network coolify exists...
Ensuring network havzqyfu212ybs9n5lg48gzy exists...
Starting coolify-proxy.
ParseAddr("fdb0:c330:3639::1/64"): unexpected character, want colon (at "/64")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;There seemed to be an issue with parsing an IPv6 address. To resolve it I decided to turn off IPv6 support on the system. I opened an SSH connection to the server again to edit Docker's daemon.json file:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vi /etc/docker/daemon.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;I added an entry with &lt;code&gt;"ipv6": false&lt;/code&gt;:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "default-address-pools": [
    {"base":"10.0.0.0/8","size":24}
  ],
  "ipv6": false
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Then I restarted Docker:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;systemctl restart docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;After that I could restart the proxy and see coolify-proxy container running on the system.&lt;/p&gt;

&lt;p&gt;And that was it! If you can log in and see that the proxy is running, you are ready to start adding individual applications and services. You can still also use a hosted version of Coolify if you never set up a server before and all of this looks daunting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying resources
&lt;/h2&gt;

&lt;p&gt;Resources in Coolify needs a project, so open &lt;em&gt;Projects&lt;/em&gt; from the left side menu, and click &lt;strong&gt;+ Add&lt;/strong&gt; next to Projects. Then click &lt;strong&gt;+ New&lt;/strong&gt; next to the &lt;em&gt;Resources&lt;/em&gt; headline. You should arrive on a &lt;em&gt;New Resource&lt;/em&gt; page which lets you choose what you want to run. You can deploy directly from a git repository, Dockerfile or custom Docker Compose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhlk8fn21xy2pa5hi5xll.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhlk8fn21xy2pa5hi5xll.png" alt="Coolify applications" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  One-click services
&lt;/h3&gt;

&lt;p&gt;One-click services are pre-configured Docker Compose templates provided directly by Coolify, skipping the complexity of manual setup and configuration. They are the easiest to start with.&lt;/p&gt;

&lt;p&gt;Scroll a bit down and you should see them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ffb26wsxrw9r0a9pgvv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ffb26wsxrw9r0a9pgvv.png" alt="Coolify one-click services" width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Coolify also maintains &lt;a href="https://coolify.io/docs/services/all" rel="noopener noreferrer"&gt;a directory&lt;/a&gt; for these services on the web. Some of services you can add are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Internal tools:&lt;/strong&gt;  Appsmith, Budibase, NocoDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat &amp;amp; messaging:&lt;/strong&gt;  Matrix, Mattermost, Rocket.Chat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Development &amp;amp; CI/CD:&lt;/strong&gt;  VS Code Server, Gitea, Jenkins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring &amp;amp; analytics:&lt;/strong&gt;  Bugsink, CloudBeaver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CMS:&lt;/strong&gt;  WordPress, Ghost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are all pretested, optimized, and coming with sensible defaults.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom services
&lt;/h3&gt;

&lt;p&gt;Custom services in Coolify are those that you deploy with your own &lt;strong&gt;Docker Compose&lt;/strong&gt; file. They require a little bit more work, but allow you to run almost anything. Have a look at &lt;a href="https://serpapi.com/blog/self-host-serpbear-coolify/" rel="noopener noreferrer"&gt;how to deploy SerpBear&lt;/a&gt;, a SEO tool for keyword tracking as an example of a custom service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;If you are new to self-hosting make sure to learn a little bit more about SSH, DNS, Linux, Docker, and related topics. You should also have a look at the Coolify &lt;a href="https://coolify.io/docs/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; for even more information and tips.&lt;/p&gt;

</description>
      <category>selfhosting</category>
    </item>
    <item>
      <title>I wrote a handbook for Kamal</title>
      <dc:creator>Josef Strzibny</dc:creator>
      <pubDate>Fri, 05 Apr 2024 08:49:28 +0000</pubDate>
      <link>https://dev.to/strzibny/i-wrote-a-handbook-for-kamal-1d76</link>
      <guid>https://dev.to/strzibny/i-wrote-a-handbook-for-kamal-1d76</guid>
      <description>&lt;p&gt;Kamal is an imperative deployment tool. It's basically a successor to Capistrano, but for a container era. It's a simple wrapper around Docker and that's the whole beauty of it. 37signals created Kamal to self-host Basecamp and Hey as part of their pull out of the cloud (running managed K8s).&lt;/p&gt;

&lt;p&gt;I am always for simplicity when it comes to deployment and the truth is that lots of projects out there don't need the fully-featured Kubernetes to run. When Kamal was released I got intrigued and slowly adopted the tool. Nowadays I deploy all new projects with it.&lt;/p&gt;

&lt;p&gt;Since the documentation is a little sparse at the moment and some people trying Kamal abandoned the effort when they faced their first issues, I decided to do something about it and wrote 'the missing manual' to Kamal called &lt;a href="https://kamalmanual.com/handbook/"&gt;Kamal Handbook&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;The book goes through all the important aspects of deploying with Kamal, describes the design choices and explains what's happening under the hood with illustrations. It's by design a small book you can read in a weekend. Hope you'll like it.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>docker</category>
      <category>kamal</category>
    </item>
    <item>
      <title>I just released InvoicePrinter 2.0</title>
      <dc:creator>Josef Strzibny</dc:creator>
      <pubDate>Wed, 02 Oct 2019 11:50:08 +0000</pubDate>
      <link>https://dev.to/strzibny/i-just-released-invoiceprinter-2-0-5da0</link>
      <guid>https://dev.to/strzibny/i-just-released-invoiceprinter-2-0-5da0</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/strzibny/invoice_printer"&gt;InvoicePrinter&lt;/a&gt; is a Ruby library, server, and command-line client to make beautiful PDF invoices without browsers in no time. It's pure Ruby, but can be also used outside the Ruby world as a Docker container with a simple JSON API.&lt;/p&gt;

&lt;p&gt;New features in 2.0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New modern look &amp;amp; feel&lt;/li&gt;
&lt;li&gt;More flexible buyer/seller boxes&lt;/li&gt;
&lt;li&gt;Server is decoupled to a separate gem &lt;code&gt;invoice_printer_server&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;New bundled fonts in a separate gem &lt;code&gt;invoice_printer_fonts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Official Docker image&lt;/li&gt;
&lt;li&gt;Prawn 2.2&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the project &lt;a href="https://github.com/strzibny/invoice_printer"&gt;GitHub page&lt;/a&gt;, in the &lt;code&gt;examples/&lt;/code&gt; directory you can find a lot of code examples and how they look as PDFs. Follow &lt;code&gt;docs/&lt;/code&gt; to learn more about using it as a library, server or a command-line client.&lt;/p&gt;

&lt;p&gt;Here is one example (&lt;a href="https://github.com/strzibny/invoice_printer/raw/master/examples/promo_a4.pdf"&gt;download the final PDF&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--YoDO3fMc--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://thepracticaldev.s3.amazonaws.com/i/5ro56bflouziq6puy2kx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--YoDO3fMc--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://thepracticaldev.s3.amazonaws.com/i/5ro56bflouziq6puy2kx.jpg" alt="Alt Text" width="800" height="1132"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And Ruby code to generate it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env ruby&lt;/span&gt;
&lt;span class="c1"&gt;# This is an example of a international invoice with Czech labels and English translation.&lt;/span&gt;

&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s1"&gt;'invoice_printer'&lt;/span&gt;

&lt;span class="n"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s1"&gt;'Faktura'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;provider: &lt;/span&gt;&lt;span class="s1"&gt;'Prodejce'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;purchaser: &lt;/span&gt;&lt;span class="s1"&gt;'Kupující'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;tax_id: &lt;/span&gt;&lt;span class="s1"&gt;'IČ'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;tax_id2: &lt;/span&gt;&lt;span class="s1"&gt;'DIČ'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;payment: &lt;/span&gt;&lt;span class="s1"&gt;'Forma úhrady'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;payment_by_transfer: &lt;/span&gt;&lt;span class="s1"&gt;'Platba na následující účet:'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;account_number: &lt;/span&gt;&lt;span class="s1"&gt;'Číslo účtu'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;issue_date: &lt;/span&gt;&lt;span class="s1"&gt;'Datum vydání'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;due_date: &lt;/span&gt;&lt;span class="s1"&gt;'Datum splatnosti'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;item: &lt;/span&gt;&lt;span class="s1"&gt;'Položka'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;quantity: &lt;/span&gt;&lt;span class="s1"&gt;'Počet'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;unit: &lt;/span&gt;&lt;span class="s1"&gt;'MJ'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;price_per_item: &lt;/span&gt;&lt;span class="s1"&gt;'Cena za položku'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;amount: &lt;/span&gt;&lt;span class="s1"&gt;'Celkem bez daně'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;subtotal: &lt;/span&gt;&lt;span class="s1"&gt;'Cena bez daně'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;tax: &lt;/span&gt;&lt;span class="s1"&gt;'DPH 21 %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;total: &lt;/span&gt;&lt;span class="s1"&gt;'Celkem'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Default English labels as sublabels&lt;/span&gt;
&lt;span class="n"&gt;sublabels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;InvoicePrinter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PDFDocument&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DEFAULT_LABELS&lt;/span&gt;
&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="ss"&gt;sublabels: &lt;/span&gt;&lt;span class="n"&gt;sublabels&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;first_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;InvoicePrinter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s1"&gt;'Konzultace'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;quantity: &lt;/span&gt;&lt;span class="s1"&gt;'2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;unit: &lt;/span&gt;&lt;span class="s1"&gt;'hod'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;price: &lt;/span&gt;&lt;span class="s1"&gt;'Kč 500'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;amount: &lt;/span&gt;&lt;span class="s1"&gt;'Kč 1.000'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;second_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;InvoicePrinter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s1"&gt;'Programování'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;quantity: &lt;/span&gt;&lt;span class="s1"&gt;'10'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;unit: &lt;/span&gt;&lt;span class="s1"&gt;'hod'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;price: &lt;/span&gt;&lt;span class="s1"&gt;'Kč 900'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;amount: &lt;/span&gt;&lt;span class="s1"&gt;'Kč 9.000'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;provider_address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;ADDRESS&lt;/span&gt;&lt;span class="sh"&gt;
Rolnická 1
747 05  Opava
Kateřinky
&lt;/span&gt;&lt;span class="no"&gt;ADDRESS&lt;/span&gt;

&lt;span class="n"&gt;purchaser_address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;ADDRESS&lt;/span&gt;&lt;span class="sh"&gt;
Ostravská 1
747 70  Opava
&lt;/span&gt;&lt;span class="no"&gt;ADDRESS&lt;/span&gt;

&lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;InvoicePrinter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;number: &lt;/span&gt;&lt;span class="s1"&gt;'č. 198900000001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;provider_name: &lt;/span&gt;&lt;span class="s1"&gt;'Petr Nový'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;provider_lines:  &lt;/span&gt;&lt;span class="n"&gt;provider_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;provider_tax_id: &lt;/span&gt;&lt;span class="s1"&gt;'56565656'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;purchaser_name: &lt;/span&gt;&lt;span class="s1"&gt;'Adam Černý'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;purchaser_lines: &lt;/span&gt;&lt;span class="n"&gt;purchaser_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;issue_date: &lt;/span&gt;&lt;span class="s1"&gt;'05/03/2016'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;due_date: &lt;/span&gt;&lt;span class="s1"&gt;'19/03/2016'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;subtotal: &lt;/span&gt;&lt;span class="s1"&gt;'Kč 10.000'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;tax: &lt;/span&gt;&lt;span class="s1"&gt;'Kč 2.100'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;total: &lt;/span&gt;&lt;span class="s1"&gt;'Kč 12.100,-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;bank_account_number: &lt;/span&gt;&lt;span class="s1"&gt;'156546546465'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;account_iban: &lt;/span&gt;&lt;span class="s1"&gt;'IBAN464545645'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;account_swift: &lt;/span&gt;&lt;span class="s1"&gt;'SWIFT5456'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;items: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;first_item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;second_item&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="ss"&gt;note: &lt;/span&gt;&lt;span class="s1"&gt;'Osoba je zapsána v živnostenském rejstříku.'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="no"&gt;InvoicePrinter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;document: &lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;labels: &lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;font: &lt;/span&gt;&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'../../assets/fonts/overpass/Overpass-Regular.ttf'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;__FILE__&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="ss"&gt;logo: &lt;/span&gt;&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'../logo.png'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;__FILE__&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="ss"&gt;file_name: &lt;/span&gt;&lt;span class="s1"&gt;'promo.pdf'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;page_size: :a4&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the final release I wrote a little background post on my &lt;a href="http://nts.strzibny.name/invoiceprinter-2-0/"&gt;blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Try it out and let me know what you think!&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>docker</category>
      <category>pdf</category>
      <category>invoicing</category>
    </item>
    <item>
      <title>Django 2.2 polls app tutorial source code commit by commit</title>
      <dc:creator>Josef Strzibny</dc:creator>
      <pubDate>Tue, 09 Apr 2019 22:47:01 +0000</pubDate>
      <link>https://dev.to/strzibny/django-2-2-polls-app-tutorial-source-code-commit-by-commit-3glk</link>
      <guid>https://dev.to/strzibny/django-2-2-polls-app-tutorial-source-code-commit-by-commit-3glk</guid>
      <description>&lt;p&gt;TL;DR&lt;/p&gt;

&lt;p&gt;I went through the famous Django "polls" app tutorial, and made it into a git repository which you can follow commit by commit:&lt;/p&gt;

&lt;p&gt;GitHub link: &lt;a href="https://github.com/deployment-from-scratch/django-2.2-polls"&gt;deployment-from-scratch/django-2.2-polls&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tutorial link: &lt;a href="https://docs.djangoproject.com/en/2.2/intro/tutorial01/"&gt;Writing your first Django app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's for Django 2.2.&lt;/p&gt;

&lt;p&gt;Full Story:&lt;/p&gt;

&lt;p&gt;Since the beginning of time (me wanting to leave PHP development) I was intrigued by Ruby on Rails and Django frameworks. I liked both for different reasons, but ended up doing mainly Rails because of Ruby the language (and living with a syndrome that I might made a mistake ever since).&lt;/p&gt;

&lt;p&gt;Fast forward to 2019, and I am writing a book on &lt;a href="http://deploymentfromscratch.com"&gt;deploying web application&lt;/a&gt; where I am trying to teach deployment of both Ruby &lt;em&gt;and&lt;/em&gt; Python apps. There are pretty similar in that regards actually, but I needed some Python examples.&lt;/p&gt;

&lt;p&gt;For this reason, I went to the Django site after many years (it's a lie, I keep watching Django space haha) and did the famous introductory tutorial of building "polls" app. I did it "commit by commit" so people can follow it easily while going through the tutorial.&lt;/p&gt;

</description>
      <category>python</category>
      <category>django</category>
      <category>beginners</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
