Self-Hosting AI Apps Locally on a Budget

Self-Hosting AI Apps Locally on a Budget
Photo by Nikita Kachanovsky / Unsplash

Introduction

In a recent internal AI training I conducted for our engineers, we used locally hosted and cloud hosted software like n8n and flowise to learn some low-code platforms that can be used to create quick and capable Agentic AI apps or Agentic Workflows easily.

The participants (who were all eager tinkerers as well) were eager to understand how this can be done at their home. Since there was enough interest in this topic I decided to put together this starters edition of self-hosting locally on a budget, rather than tell them go search on the internet. In any case I was planning to document my basic self-hosted setup, so this seemed like a good opportunity.

I also plan to create a 'Self-hosting AI Apps in cloud on a Budget' version of this document as well, assuming Oracle Cloud continues to gives the generous 4 Core Ampere VPC instances with 24 GB RAM and 200 GB of storage for free. I have been using one such instance for years now and it had never let me down, despite the fact that it's free.

In this document we cover operating system setup, install tools needed, install the following apps to start with.

  • n8n
  • flowise
  • qdrant
  • docling-serve

I will also show how to install ollama, cpu only version in the VM I will be setting up for creating this document. You can practice on a VM in VirtualBox before installing on bare metal.

While this allows access from within the local network, I will also add a follow up document later with optional steps for setting up a basic tailscale mesh VPN to allow the developers to connect over the internet to their services, securely.

Note: This is a very long article meant for beginners, so be ready to commit to the process. Feel free to skip to the appropriate sections as needed.

Disclaimer

This post is for educational purposes only and is meant to act as a guide. The techniques mentioned in the post is good enough for a home lab and covers just the basics needed for you to get started when it comes to exploring AI tools.

Hardware

Dedicated Hardware

I am a big believer of separation of concerns, and I sort of extend this to the hardware I use for hosting services at home. If your budget allows it, please use this option to avoid performance issue impact on your main machine.

You don't really need to spend a lot if you are just getting started, in fact I would suggest reusing an old 64 bit x86 laptop or computer you may have at home or even an old intel mac. In fact, one of my "servers" where I host my flowise and n8n are running on an old Lenovo Thinkpad X280 from 2016 or 2017 that I picked up refurbished on Amazon Japan. I wouldn't suggest the same model due to a lack of Ethernet port which meant I had to buy a USB-C to Ethernet adapter.

If your laptop or old PC has a 64-bit processor, 8 GB RAM or more and SSD / NVME based storage, you are set, but you can make it work with 4 GB RAM also. Just remember to backup the data you need from your older device, as I would recommend doing a clean install of Linux (like Ubuntu Server 24.04, the LTS - Long Term Support - version as of the time of writing this post).

If you don't want have a spare PC at home like I did, then here are a few recommendation to get started the easy way. The devices cost anywhere from 20000-45000 Yen, but you can easily recover the cost when compared to using x86 cloud instances with similar configurations. For example: Major cloud providers charges 30 yen to 70 yen per hour, plus storage costs, plus static IP costs, if you want a static IP.

Even a cheap dedicated x86 VPS from smaller cloud provides with 8 GB RAM but with just 2-4 threads or vCPUs easily costs 13-15 dollars per month, but using this take away a lot of the setup work out of your hands. This can be another option if you prefer to get into hosting in the cloud directly and don't want to spend ~200 USD upfront. In you like to go with this option, I have heard Hetzner and Hostingger are good choices.

If you want to look at the option of hosting from home, the read on. As we are targeting x86 architecture, we will park the Raspberry Pi on the side for now, unless and until you own a Raspberry Pi 4 or 5 with at least 4 GB of RAM already.

When I started writing this post, for the cost of buying a new Raspberry Pi 5, a good case with cooling fans, a good 256 GB SD card, and a decent power supply, you can buy one of the N150 devices I suggest below. (I will later be exploring hosting these services on my Raspberry Pi 4 and 5, and add it as a follow up post with the cost breakdown, based on the latest prices).

Note: Any links in this post and any posts to follow, are non-affiliate links and I don't make any money from these soft suggestions. These are just guidance for you to follow, you are free to ignore them.
  • Search for 'AMD Mini PC' and you will be able to find a lot of options from AMD in the 35000 Yen - 45000 Yen range. Avoid 35xx range as the chips are old. If you spend a bit more, you can get a relatively modern SMT CPU like Ryzen 5825 or even newer ones.
  • If you want to remain in the sub 25000 Yen range then 'Intel N150' based Mini PC with 4 non-SMT cores, are good options. Choose one with minimum 8 GB RAM and 256 GB storage, but if you can afford 16 GB RAM and 512 GB storage, you should be relatively future-proof for a while when it comes to self-hosting some of the tools you would like to use.
Note: Given the state of RAM pricing these days, even the N150 computer that I acquired for around ~22,000 yen is out of stock, and N100 or N150 mini-pcs are selling for ~30,000 yen these days. So, if you have an old laptop lying around, that should be your first preference. Also, a Raspberry Pi 5 kit with 8 GB RAM is also an option to consider as these are available for less than 20,000 yen as I am writing this post.

If you are okay with renewed products, you will see a lot of options in the Amazon Renewed section. Just aim for a laptop with at least a intel 8th gen i5 or i7 processor or newer with 8 GB RAM and 256 GB storage. If the RAM can be upgraded, even better. I personally like Dell Latitudes and Lenovo Thinkpads, especially if you can find one at around 25000 Yen point.

Shared Hardware

If you are using a Shared Hardware or don't want the hassle of setting up a Linux server, then this is also and option to consider. In this post I am focusing on dedicated hardware only, as that is the most hassle free option for self hosting.

You can install Oracle VirtualBox, install Ubuntu server in a Virtual Machine by following along with the instructions below.

Operating System Installation

We are going to cover Ubuntu Server LTS version Installation in this section.

Note: (24.04 as of writing this post)

Follow the instructions in either of the following videos if you prefer video instructions.

https://youtu.be/n7aEcfDNULc?si=AMcQ_Bzi362De8J6
or
https://youtu.be/pln-tglFxwg?si=OBhpNc6q32AI-YId

If there are enough requests, I will make a video on how I installed Ubuntu 24.04 on my N150 mini PC. I will have to nuke my current installation and restart the whole process, but happy to do that if needed. Instead, I am including the screenshots from my VirtualBox installation in this post.

Note: If this is the first time for you to install Ubuntu server, please practice the installation in VirtualBox before you do it on bare-metal.

For people doing this installation on bare-metal, make sure that your "server" is hooked up to Ethernet or LAN if possible, otherwise follow the instructions to connect to WiFi. Connecting to Ethernet ensures that you have a stable internet connection when you are installing and/or updating software, as well as a stable connection when you connect to the applications over your private network.

This way, you are not at the mercy of the WiFi drivers being in the Linux Kernel (I have mostly had no issues with WiFi drivers till now, except when I had to install Ubuntu on my old Mac Book Air from 2015, but that's a story for another time) and the WiFi signal strength.

Note: VirtualBox by default "virtualizes" your existing network connection as an Ethernet connection. I don't know how that works, but it works

Ubuntu Server Installation

Here, I assume that you have already installed VirtualBox on an x86 system, as I am not sure if these instructions works on Apple Silicon or other ARM platforms. I am using VirtualBox 7.2 as of this writing on Fedora 43 running KDE Plasma.

The OS installation steps are nearly the same for bare metal hardware, so the steps I have captured in the VM is applicable for bare metal installs as well.

Ubuntu Server ISO download

Go to https://ubuntu.com/download/server and download the ISO file for Ubuntu server 24.04 by clicking the green button that says Download 24.04.3 LTS.

The last digit may have changed, based on the updates included into the LTS version.

In case you are reading this post April 2026, then 26.04 LTS version may have come out by then, but the steps I am noting down here should be valid at that time as well, if not, I will update this post when the time comes.

I suggest using LTS versions as they get standard software and security updates for 5 years (or up to 10 years if you sign up for a free Ubuntu pro account). Non-LTS versions are supported only for 9 months.

LTS versions are release on even years, in April and named as such like YY.MM (where YY is the last two digit of the year and MM is the month). Ubuntu releases new version of their distribution in April and October (usually). So we are using Ubuntu LTS version release in 2024 in April hence also called Ubuntu 24.04.

Virtual Machine Setup

The following screenshots show the details of the VM I am creating for this post. You get to the following setup screens by clicking the new button or by using the keyboard shortcut Ctrl + N.

Note: I am in expert mode here so your VirtualBox screen may be different.

Once the New Virtual Machine wizard open, fill out the following details so that you can proceed to the next step.

Field Name Value
VM Name Identifier for the machine, here I have named my VM Ubuntu 24.04
VM Folder The Folder where VM details and Virtual hard disk is stored
ISO Image The location of the ISO Image you downloaded from the ubuntu website
OS Should automatically be selected to Linux
OS Distribution Should automatically be selected to Ubuntu
OS Version Should automatically be selected to Ubuntu (64-bit)

I like to control the installation by myself, so I unchecked Proceed with Unattended Installation. All the following instruction are based on the assumption that Proceed with Unattended Installation is unchecked.

Next expand the Specify virtual hardware section and allocate CPU threads and RAM to the VM. In my case I have given my VM 4 CPU and 8192 MB of RAM. Allocate at least 2 CPU and 4096 MB of RAM for what we are going to do, especially for the optional stuff I have added towards the end.

Next expand Specify virtual hards disk section. The default values are good as it allows for you to install all software and packages, but I am allocating 64 GB as I have enough storage and resizing the Virtual Hard Disk later is something I don't want to do.

Click Finish to create your virtual hardware. You should be able to see something like what is shown below.

I generally do some additional tweaking to the settings, but I am not sure if it is needed. Over the years, this has become the part of the workflow for me in VirtualBox, so I do it anyways. Feel free to do the same.

Additional VM Settings

Select the newly create VM and click the Settings button or use the keyboard shortcut Ctrl+S.

Since I am in expert mode, I am shown the expert screen as shown below, please switch to the expert mode to make the below changes.

Click System to bring to scroll to system options.

Note: You can resize the settings windows as needed for more real-estate.

In the Motherboard tab un-check Floppy as it seems modern distros don't need floppy (apparently).

The select the the Processor tab and make the following changes in Features section.

Next Select Storage click on your VDI in SATA controller, in my case Ubuntu 24.04.vdi and in Atrributes check Solid-state Drive. I don't know what it does, but I do it for all my Linux VMs.

Now click the Network tab below to setup port forwarding. This is optional, but highly recommended as it allows you to SSH into the VM from your PC allowing you to copy and paste commands into a terminal software like putty in Windows. In later versions of Windows 10 and in Windows 11, Linux or MacOS you can use the inbuilt ssh from terminal command to connect to the VM from the PC you have installed the VM in.

Note: port-forwarding needs to be done only for the VM in VirtualBox (or other virtualization software).

In the above screen ensure click the Port Forwarding button to bring up the port forwarding rules window and then click the green button with '+' on it on the right side to bring up the port forwarding options.

Double click into each cell and change the values as shown below. What we are doing is telling VirtualBox that to connect to this VM over ssh, the Host Port 22022, that is our PC's port on localhost will be forwarded to the ssh port 22 in the VM. I selected 22022 randomly but the convention I follow is to select a value that ends with 22 and go with a unused port number greater than 1000 and less than 65000.

Click OK button to save these changes. Later I will show how to connect to this VM via ssh and add an ssh key to make the login simpler and to harden your system a little bit.

Once the above changes are made, click the OK button to save these changes.

You are now ready for Ubuntu Server installation on the newly created VM.

Installation Process (Shown for VM Setup above)

Starting the VM

Select the VM, click the start button button to start the VM and launch the installation process.

The VM should launch (I couldn't capture the splash screen) and you should soon arrive at the below screen. The first option is selected by default as shown below.

The default option is good for most hardware, but HWE or Hardware Enablement Kernel brings in new Kernel features and newer drivers to an LTS distribution. This is especially useful when it comes to making Linux work with new devices.

We will go with the default option for the VM, As I got some errors in my VM when I used HWE Kernel and had to repeat the whole process again.

On my bare metal hardware, I had no issues with HWE Kernel, but I always installed HWE Kernel after Ubuntu server was installed.

Installer Auto-launch

Once the option you want is selected, hit the Enter button to launch the installer. You should see a bunch of scrolling text as captured in the screen show below. This is the Linux boot up sequence, so allow it to come to the installer screen.

Language and Keyboard Selection

This is the installer screen that will be launched automatically once the boot up sequence is completed. As you can see 'English' is selected by default.

You can select other languages by pressing the up/down keys on your keyboard. Once the language is selected, press the Enter key. This will take you to the keyboard configuration where you can select the required keyboard configuration. You can use the up/down keys to select the layout or variant options and pressing Enter will open a menu as shown below.

Keyboard Layout Options:

Keyboard Variant Options:

As I am working with a US keyboard, I will hit the Enter key here without changing the defaults.

Server Type Selection

In the next screen, I have enabled the 'search for additional drivers' option by pressing the up/down keys to move the cursor to the appropriate check box and then pressing the space bar. This is to ensure that all possible device drivers are installed at the time of operating system install.

Network and Proxy Settings

The following screen is where you do the network selection. Since we are doing this in a VM it has detected to the available virtual Ethernet and allocated a IP address.

If you are doing this on bare metal, and you are planning to use WiFi, then you will have to select your network by yourself. It will be something with a name like wlp2s0 which is what I have on my computer. You will then have to enter the WiFi Name and password to connect to the internet. You will know that your connection is active when an IP address is allocated to you.

Once the IP address is allocated, scroll to the Done option and press Enter key.

Note: If you are installing ubuntu server on an old intel era mac book, due to the fact that those devices use broadcom WiFi chips, you may not be able to connect to the internet via WiFi. This was the case with Ubuntu 22.04.

You will be taken to the proxy select screen like below. If you are in a home network like I am, you can press enter directly. Else find out the proxy address and enter that information, especially in case you are in a corporate network.

The next screen is a mirror check screen to connect to Ubuntu's online repositories. Since we are in Japan, the installer is automatically picked up the Japan mirror. In your case, the system will automatically pickup the nearest locale and test the connection, assuming the system could establish a network connection.

Press the Enter key to get to the disk partition or allocation screen.

Disk Settings

Here use to up/down key to move the cursor to the Set up this disk as LVM group option, and press the space key to unselect that option. We don't need LVM for a simple home server setup, but this is recommended for production grade servers.

LVM (Logical Volume Manager) in Linux is a flexible storage management system that adds a layer of abstraction between physical disks and file systems, allowing administrators to pool storage, resize partitions dynamically (even while running), span volumes across multiple drives, and use features like snapshots and mirroring for easier management and advanced data protection. It works by creating Physical Volumes (PVs) from disks/partitions, grouping them into Volume Groups (VGs), and then carving out Flexible Logical Volumes (LVs) from the VG for your filesystem.

Once that is done you are taken to the confirmation screen for the installation as shown below.

Hit the down key to select Continue, and you will see it will turn Ubuntu to red/orange like shown below.

User ID Creation

You will now arrive at the profile configuration screen where you can enter the required details and create a password. This user will have admin or sudo privileges by default.

You can move between these fields by pressing the tab key. All the fields are required fields. Once the value are entered press the up/down keys or tab key to select Done option.

Ubuntu Pro Screen

You can leave the Ubuntu Pro screen options with default value and skip this for now. Press Enter to continue.

OpenSSH Install Screen

In the next screen enable Install OpenSSH Server option as this will allow you to connect to the server remotely using ssh connection. This is useful as you wouldn't have to physically be at your server if you want to install something new or if you want to remote administer it.

Third Part Drivers Screen

Press Enter to continue. This will bring you to the third party drivers screen. In this VM there are no third party drivers that are detected. I think for people with Broadcom WiFi chips you may see an option here, but I am not a 100% sure about that.

Skip everything in the next screen. For our case, we don't need any of these extras.

Install Screen

This will take you to the system install screen where finally ubuntu installation will happen.

Once the installation is complete a Reboot Now option will appear. You can select that by hitting the Tab key until Reboot Now is highlighted as shown below.

Press enter to reboot the system. You may need to hit Enter again to start the reboot, after the system ejects the virtual CD-ROM (essentially your ISO file).

First Boot

After a minute or two you will be shown the login screen. The first boot on bare metal take way longer that that, but in this case as I was in a VM it was done in one minute.

If you see the below text, press Enter key to get to the login prompt. This will happen only for the first login, when system is initializing for the first time.

After pressing the Enter key you will see the login prompt.

Here enter your username and password. When you enter the password there will be no visual feedback like ****, so just trust that the values are accepted. You will be shown the below screen.

The first boot usually takes a bit longer to complete as it does some system initialization so allow the system to finish initialization process. This is especially true if you are on bare metal and connected to WiFi.

Once the operating system is installed, system is rebooted, and you have logged in for the first time, you are ready to login to the system via ssh. Technically once you see the login prompt especially in a bare metal system, you can log in via ssh, but for first boot, it's better to ensure that you see the above login screen.

With VirtualBox VM, its better to login to the VM directly first and then ssh in later via terminal app or Putty.

Login with ssh

Anyways, I login via ssh to my VM using the following command.

# here pass the port you specified instead of `22022` and your username instead of -p
ssh -p 22022 vijaymenon@127.0.0.1 

On the first login you will see the following message, type yes to proceed. This will add the ip:port combination to known_hosts. The type enter your password

The authenticity of host '[127.0.0.1]:22022 ([127.0.0.1]:22022)' can't be established.
ED25519 key fingerprint is SHA256:I07yld+CdBF1JOry3LxDeFCaOh36VzqaM5WSFR3uKwc.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '[127.0.0.1]:22022' (ED25519) to the list of known hosts.
vijaymenon@127.0.0.1's password:

Package Update and Upgrade

Once you have logged into the system, copy the following commands and run them in the terminal to install the latest updates before we proceed to the next steps.

sudo apt update # Refersh the packages list  
sudo apt dist-upgrade -y # update the latest packages 
sudo apt autoremove -y # remove the old packages left behind after distibution upgrade 
sudo apt autoclean -y # autoclean the system
Info: If you get a apt lock message, please wait for a few minutes to ensure the lock is released.
Note: You can use Ctrl+Shift+C and Ctrl+Shift+V to copy and paste into nano / fresh editor in terminal.

For sudo commands, you need to enter your password as you are elevating the privileges to superuser or admin in Linux.

You will see a bunch of scrolling text, which is update and upgrade logs. Wait for the upgrade to complete. You know then when your input prompt is available again.

vijaymenon@ubuntu-vm:~$ sudo apt update # Refersh the packages list
sudo apt dist-upgrade -y # update the latest packages
sudo apt autoremove -y # remove the old packages left behind after distibution upgrade
sudo apt autoclean -y # autoclean the system
[sudo] password for vijaymenon:
Hit:1 http://security.ubuntu.com/ubuntu noble-security InRelease
Hit:2 http://jp.archive.ubuntu.com/ubuntu noble InRelease
Hit:3 http://jp.archive.ubuntu.com/ubuntu noble-updates InRelease
Hit:4 http://jp.archive.ubuntu.com/ubuntu noble-backports InRelease
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
59 packages can be upgraded. Run 'apt list --upgradable' to see them.
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
Calculating upgrade... Done
The following packages will be upgraded:
  apparmor bsdextrautils bsdutils cloud-init coreutils dhcpcd-base distro-info-data eject fdisk fwupd gir1.2-glib-2.0 gir1.2-packagekitglib-1.0
  landscape-common libapparmor1 libblkid1 libdrm-common libdrm2 libfdisk1 libfwupd2 libglib2.0-0t64 libglib2.0-bin libglib2.0-data libmbim-glib4 libmbim-proxy
  libmbim-utils libmount1 libnetplan1 libnss-systemd libpackagekit-glib2-18 libpam-systemd libsmartcols1 libsystemd-shared libsystemd0 libudev1 libuuid1 mount
  netplan-generator netplan.io packagekit packagekit-tools powermgmt-base Python3-netplan Python3-software-properties snapd software-properties-common
  sosreport systemd systemd-dev systemd-hwe-hwdb systemd-resolved systemd-sysv systemd-timesyncd tcpdump ubuntu-drivers-common ubuntu-pro-client
  ubuntu-pro-client-l10n udev util-linux uuid-runtime
59 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Need to get 57.2 MB of archives.
After this operation, 3,950 kB of additional disk space will be used.
Get:1 http://jp.archive.ubuntu.com/ubuntu noble-updates/main amd64 bsdutils amd64 1:2.39.3-9ubuntu6.4 [95.6 kB]
Get:2 http://jp.archive.ubuntu.com/ubuntu noble-updates/main amd64 coreutils amd64 9.4-3ubuntu6.1 [1,413 kB]
Get:3 http://jp.archive.ubuntu.com/ubuntu noble-updates/main amd64 util-linux amd64 2.39.3-9ubuntu6.4 [1,128 kB]
Get:4 http://jp.archive.ubuntu.com/ubuntu noble-updates/main amd64 mount amd64 2.39.3-9ubuntu6.4 [118 kB]
...
...
...
No VM guests are running outdated hypervisor (qemu) binaries on this host.
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
vijaymenon@ubuntu-vm:~$

It's good idea to reboot the machine after the first system upgrade which is done by the command in the code block below.

You wouldn't need to do this every time unless and until you see a message that explicitly asks you to reboot the system when you login.

# optional, but recommend for the first time upgrade 
sudo reboot

You will see something like the the following message confirming the system is rebooting.

vijaymenon@ubuntu-vm:~$ sudo reboot

Broadcast message from root@ubuntu-vm on pts/1 (Sun 2025-12-21 02:58:28 UTC):

The system will reboot now!

vijaymenon@ubuntu-vm:~$ Connection to 127.0.0.1 closed by remote host.
Connection to 127.0.0.1 closed.

If you want to shutdown your instance use the poweroff command or shutdown now as shown below.

sudo shutdown now 

This will ask for sudo password and turn off the machine (gracefully).

vijaymenon@ubuntu-vm:~$ sudo shutdown now

Broadcast message from root@ubuntu-vm on pts/1 (Wed 2025-12-31 02:59:24 UTC):

The system will power off now!

vijaymenon@ubuntu-vm:~$ Connection to 127.0.0.1 closed by remote host.
Connection to 127.0.0.1 closed.

Enabling ZRAM in Ubuntu

If you are working with relatively small amounts of memory and I mean less that 16 GB RAM, the I would also suggest enabling zram to allow for swapping to compressed memory in RAM itself.

Google's AI Overview says this:

ZRAM works by creating a compressed block device within your computer's RAM, acting as a super-fast swap space; instead of moving inactive data to slow disk storage when RAM fills up, it compresses that data and keeps it in RAM, effectively giving you more usable memory by using CPU cycles for compression/decompression, which is much faster than disk I/O, boosting performance, especially on low-RAM systems.
Note: these are the commands for Ubuntu Distribution. for other distros please refer to relevant documents, that may be found on the internet.

Install zram-tools using the below command

sudo apt update && sudo apt install zram-tools 

Now edit the zram config file using the nano editor with command sudo nano /etc/default/zramswap and change the file to make it look like the following.

# Compression algorithm selection
# speed: lz4 > zstd > lzo
# compression: zstd > lzo > lz4
# This is not inclusive of all that is available in latest kernels
# See /sys/block/zram0/comp_algorithm (when zram module is loaded) to see
# what is currently set and available for your kernel[1]
# [1]  https://github.com/torvalds/linux/blob/master/Documentation/blockdev/zram.txt#L86
ALGO=zstd

# Specifies the amount of RAM that should be used for zram
# based on a percentage the total amount of available memory
# This takes precedence and overrides SIZE below
PERCENT=100

# Specifies a static amount of RAM that should be used for
# the ZRAM devices, this is in MiB
#SIZE=256

# Specifies the priority for the swap devices, see swapon(2)
# for more details. Higher number = higher priority
# This should probably be higher than hdd/ssd swaps.
PRIORITY=100

Run the following commands to enable and restart the zram service.

sudo systemctl enable zramswap
sudo systemctl restart zramswap

Confirm the new zram values were taken by running the command sudo zramctl. You should see an output like what can be seen below.

Ξ (ubuntu-vm) ~ → sudo zramctl
NAME       ALGORITHM DISKSIZE DATA COMPR TOTAL STREAMS MOUNTPOINT
/dev/zram0 zstd          7.8G   4K   59B   20K       4 [SWAP]

Basic ssh Hardening

Before we go any further, we will do some basic ssh housekeeping to harden the system a little bit.

This is highly recommended for bare metal systems in case you are planning to expose it to the internet using mesh VPN systems like tailscale or for cloud VM you may have got from Hetzner or Hostinger.

These commands while written for Linux/MacOS, should also work for Windows 11 and if needed, replace / with \ in Windows.

Generate a new ssh key

  1. Enter the command ssh-keygen to start the ssh key generation process.
  2. You will be asked to enter the storage location for the keys, and here I have given it as /home/vijaymenon/.ssh/ubuntu_vm_ed25519.
    1. A good practice is to define a unique key for each of your server.
  3. You will be asked to enter a passphrase and reconfirm the same. I left it as empty by pressing the enter key.
  4. You new key is generated and stored in the location you have given in step 2.
Ξ ~ → ssh-keygen
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/vijaymenon/.ssh/id_ed25519): /home/vijaymenon/.ssh/ubuntu_vm_ed25519
Enter passphrase for "/home/vijaymenon/.ssh/ubuntu_vm_ed25519" (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/vijaymenon/.ssh/ubuntu_vm_ed25519
Your public key has been saved in /home/vijaymenon/.ssh/ubuntu_vm_ed25519.pub
The key fingerprint is:
SHA256:ZUuhQWepRlDZtCjPoFLj+xvEQ/4rrEqVLIxiadfQTKk vijaymenon@the-fedora
The key's randomart image is:
+--[ED25519 256]--+
|     .o+++=.     |
|    +.  o*oo     |
|   .+o+.o.=      |
| o.EoB =o+ .     |
|o++.*.=.S .      |
|+ .+ o o         |
|  . ... .        |
| .   .o. .       |
|  ....oo.        |
+----[SHA256]-----+

These steps generate two files, one a public key ending with .pub extension and another a private key without any extension. Never share the private key with anyone.

Ξ ~/.ssh → ll ubuntu_vm_ed25519*
-rw-------. 1 vijaymenon vijaymenon 411 Dec 21 12:33 ubuntu_vm_ed25519
-rw-r--r--. 1 vijaymenon vijaymenon 103 Dec 21 12:33 ubuntu_vm_ed25519.pub

Push the public key to your server

Run the following command to push the key to your server. Remember to change the name of the public key, the port and the user ID.

# syntax: ssh-copy-id -i <public_key_path> -p <host_ssh_port> <user_name>@<server-ip> 
ssh-copy-id -i ~/.ssh/ubuntu_vm_ed25519.pub -p 22022 vijaymenon@127.0.0.1

You will be prompted to enter the password for your username as shown below. One the key is copied to the server, you will see the confirmation as shown below. You will also get a command to test the ssh connection.

/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/vijaymenon/.ssh/ubuntu_vm_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
vijaymenon@127.0.0.1's password:

Number of key(s) added: 1

Now try logging into the machine, with: "ssh -i /home/vijaymenon/.ssh/ubuntu_vm_ed25519 -p 22022 'vijaymenon@127.0.0.1'"
and check to make sure that only the key(s) you wanted were added.

Command to test the connection:

# syntax: ssh <private_key_path> -p <host_ssh_port> <user_name>@<server-ip> 
ssh -i /home/vijaymenon/.ssh/ubuntu_vm_ed25519 -p 22022 'vijaymenon@127.0.0.1'

Test the connection with the above command confirms that you can login in without sending password over the network.

Ξ ~ → ssh -i /home/vijaymenon/.ssh/ubuntu_vm_ed25519 -p 22022 'vijaymenon@127.0.0.1'
Welcome to Ubuntu 24.04.3 LTS (GNU/Linux 6.8.0-90-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Sun Dec 21 04:15:39 AM UTC 2025

  System load:             0.0
  Usage of /:              10.6% of 62.69GB
  Memory usage:            3%
  Swap usage:              0%
  Processes:               126
  Users logged in:         1
  IPv4 address for enp0s3: 10.0.2.15
  IPv6 address for enp0s3: fd17:625c:f037:2:a00:27ff:fe9e:1dd7


Expanded Security Maintenance for Applications is not enabled.


Last login: Sun Dec 21 04:07:27 2025 from 10.0.2.2
vijaymenon@ubuntu-vm:~$

Add new entry to ~/.ssh/config file

In your home directory find the .ssh folder and create a new file called config (without any extensions) using a text editor if the file is not present already. Add the following lines with the required changes. Remember to keep the indentation as shown in the example below.

Field Value
Host A shortcut identifier for the ssh connection
HostName IP address or the domain name of the Host
User VM username
Port The ssh port, usually this is 22, but for the VirtualBox VM we are forwarding Host port 22022 to Guest Port 22
Identity file Path to your private key

So for this VM I have added the following to my ~/.ssh/config file in my host machine. This can be done with any editor of your choice.

Host vbox-ubuntu-vm
    HostName 127.0.0.1
    User vijaymenon
    Port 22022
    IdentityFile ~/.ssh/ubuntu_vm_ed25519

Now you can login to your machine by simply typing ssh <shortcut identifier> as shown below

Ξ ~ → ssh vbox-ubuntu-vm
Welcome to Ubuntu 24.04.3 LTS (GNU/Linux 6.8.0-90-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Sun Dec 21 04:19:38 AM UTC 2025

  System load:             0.66
  Usage of /:              10.7% of 62.69GB
  Memory usage:            5%
  Swap usage:              0%
  Processes:               137
  Users logged in:         1
  IPv4 address for enp0s3: 10.0.2.15
  IPv6 address for enp0s3: fd17:625c:f037:2:a00:27ff:fe9e:1dd7


Expanded Security Maintenance for Applications is not enabled.

0 updates can be applied immediately.

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


Last login: Sun Dec 21 04:18:19 2025 from 10.0.2.2
vijaymenon@ubuntu-vm:~$

Further ssh hardening

We will now edit the ssh configuration file to harden the ssh server by editing the sshd config file. Enter the following command and enter your password to launch the editor.

sudo nano /etc/ssh/sshd_config

nano is simple to use and you can use up/down/left/right keys to navigate through the file.

Note: Usual shortcuts for Cut/Copy/Paste doesn't work. To copy and paste in use the mouse or the keyboard shortcuts mentioned before.
  1. Comment out the following line in the file by adding a # in the beginning. This is ensure that PasswordAuthentication is totally disabled.
Include /etc/ssh/sshd_config.d/*.conf
  1. Uncomment the following line by removing the # from beginning. This will ensure that only Public Key Auth is allowed.
#PubkeyAuthentication yes
  1. Uncomment the following line and change the yes to no. This will ensure that no one can use password authentication to login via ssh.
#PasswordAuthentication yes
  1. Uncomment the following line and change whatever value to no. This prevents people from trying to access the root account over ssh. root account is disabled by default in Ubuntu.
#PermitRootLogin no

Save the file by hitting Ctrl+O and Enter again. In modern versions of Linux distributions, Ctrl+S also works. Ctrl+X will exit nano cleanly if the file is saved.

Here are the changes that I made for confirmation

# Include /etc/ssh/sshd_config.d/*.conf
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
PrintMotd yes 

Once the file is saved, run the following command to restart the ssh daemon. You may have to enter the password again.

# this used to be `sudo systemctl restart sshd` but in 24.04 has changed to below
sudo systemctl restart ssh
sudo systemctl enable ssh

Now if you try to login using password the server will deny access like shown below.

Ξ ~ → ssh -p 22022 vijaymenon@127.0.0.1
vijaymenon@127.0.0.1: Permission denied (publickey).

At the same time, you can login with the private key as usual.

Ξ ~ → ssh vbox-ubuntu-vm
Welcome to Ubuntu 24.04.3 LTS (GNU/Linux 6.8.0-90-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Sun Dec 21 04:52:58 AM UTC 2025

  System load:             0.0
  Usage of /:              10.6% of 62.69GB
  Memory usage:            2%
  Swap usage:              0%
  Processes:               122
  Users logged in:         0
  IPv4 address for enp0s3: 10.0.2.15
  IPv6 address for enp0s3: fd17:625c:f037:2:a00:27ff:fe9e:1dd7


Expanded Security Maintenance for Applications is not enabled.

0 updates can be applied immediately.

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


Last login: Sun Dec 21 04:48:14 2025 from 10.0.2.2

Foundation Software Installation

These are software and packages I install on all* my Linux systems that I work with, including my cloud VMs. You are free to skip over the installation of these packages, but these are good for improving your terminal user experience. This is the reason why we are starting with the additional packages first.

* Note: The only exception to above is brew which is not yet supported on ARM architecture in Linux.

Utils and zsh install

Install htop btop git and zsh. htop and git might already be installed, but trying to install again doesn't harm the system, in this case.

htop and btop are used for system monitoring, and I use both of them to see the cpu and system usage snapshots.

sudo apt install htop btop git zsh -y

Install ohmyzsh to enable zsh with some additional goodies. This can be installed with the following command.

sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

You will get the following prompt, where you can type Y and press enter. You will see that your prompt has changed.

Changing your shell to /usr/bin/zsh...
Shell successfully changed to '/usr/bin/zsh'.

         __                                     __
  ____  / /_     ____ ___  __  __   ____  _____/ /_
 / __ \/ __ \   / __ `__ \/ / / /  /_  / / ___/ __ \
/ /_/ / / / /  / / / / / / /_/ /    / /_(__  ) / / /
\____/_/ /_/  /_/ /_/ /_/\__, /    /___/____/_/ /_/
                        /____/                       ....is now installed!


Before you scream Oh My Zsh! look over the `.zshrc` file to select plugins, themes, and options.

• Follow us on X: https://x.com/ohmyzsh
• Join our Discord community: https://discord.gg/ohmyzsh
• Get stickers, t-shirts, coffee mugs and more: https://commitgoods.com/collections/oh-my-zsh

➜  ~

Edit the .zshrc file using nano to change the theme.

nano .zshrc

Find the line which says ZSH_THEME="robbyrussell" and change it to ZSH_THEME="jnrowe" which is the theme I use.

You can find a full list of themes here - https://github.com/ohmyzsh/ohmyzsh/wiki/themes. But for these themes to work you will need to install a nerdfont which can be found here https://www.nerdfonts.com/. I use JetBrainsMono Nerd Font which can be found here - https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/JetBrainsMono.zip

Save and exit, and run the following command to enable your theme.

source .zshrc

And voila! you will see the new theme in your terminal.

➜  ~ source .zshrc
Ξ (ubuntu-vm) ~ →

Press Ctrl+D twice to exit the session and login again. This time onward you will login directly into zsh shell.

ohmyzsh additional plugins install

We will install and enable a couple of zsh plugins that I found very useful. They are zsh-autosuggestions and zsh-syntax-highlighting

Run the below commands first.

git clone https://github.com/zsh-users/zsh-autosuggestions.git $ZSH_CUSTOM/plugins/zsh-autosuggestions
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git $ZSH_CUSTOM/plugins/zsh-syntax-highlighting

now edit .zshrc using nano

nano .zshrc

find the plugins= line in .zshrc file. You can search in nano using the Ctrl+W shortcut.

# Which plugins would you like to load?
# Standard plugins can be found in $ZSH/plugins/
# Custom plugins may be added to $ZSH_CUSTOM/plugins/
# Example format: plugins=(rails git textmate ruby lighthouse)
# Add wisely, as too many plugins slow down shell startup.
plugins=(git)

Now change plugins=(git) line to below. Make sure there are no spelling errors and the order is followed.

plugins=(git zsh-syntax-highlighting zsh-autosuggestions)

Save and close the file. The in the terminal run source .zshrc or as we say it in Linux world "source the .zshrc file".

You will have auto suggestions like shown below, if you have used the command previously.

And you will also have syntax highlighting enabled. Green highlight means a valid command in the system, red highlight means an invalid command, like shown below.

Homebrew or brew install

We will install brew package manager to install apps in user space. This can be done by running the following command in your terminal. I took this from homebrew's home page.

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

This script will ask for sudo access password at the time of installing brew, as shown below.

Ξ (ubuntu-vm) ~ → /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
==> Checking for `sudo` access (which may request your password)...
[sudo] password for vijaymenon:

Once you enter the password and hit the Enter key again to confirm you want to install brew.

==> This script will install:
/home/linuxbrew/.linuxbrew/bin/brew
/home/linuxbrew/.linuxbrew/share/doc/homebrew
/home/linuxbrew/.linuxbrew/share/man/man1/brew.1
/home/linuxbrew/.linuxbrew/share/zsh/site-functions/_brew
/home/linuxbrew/.linuxbrew/etc/bash_completion.d/brew
/home/linuxbrew/.linuxbrew/Homebrew
==> The following new directories will be created:
/home/linuxbrew/.linuxbrew/bin
/home/linuxbrew/.linuxbrew/etc
/home/linuxbrew/.linuxbrew/include
/home/linuxbrew/.linuxbrew/lib
/home/linuxbrew/.linuxbrew/sbin
/home/linuxbrew/.linuxbrew/share
/home/linuxbrew/.linuxbrew/var
/home/linuxbrew/.linuxbrew/opt
/home/linuxbrew/.linuxbrew/share/zsh
/home/linuxbrew/.linuxbrew/share/zsh/site-functions
/home/linuxbrew/.linuxbrew/var/homebrew
/home/linuxbrew/.linuxbrew/var/homebrew/linked
/home/linuxbrew/.linuxbrew/Cellar
/home/linuxbrew/.linuxbrew/Caskroom
/home/linuxbrew/.linuxbrew/Frameworks

Press RETURN/ENTER to continue or any other key to abort:

Let the install happen and the run the following commands for zsh to add brew to your .zshrc which is like your profile which is loaded every time you open a shell session. The last line enables brew in the current session.

Note: These commands will be shown as the output once brew is installed in the system. Copy and paste them into the terminal from your installation output.
echo >> /home/vijaymenon/.zshrc
echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' >> /home/vijaymenon/.zshrc
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"

You can verify the above worked by typing brew upgrade into the terminal. This shouldn't do anything at this point of time, but also shouldn't throw any errors.

You can also install brew dependencies by using the following commands.

sudo apt install build-essential -y

We will install a couple of packages using brew now and come back to brew later. We will install lazygit, a git manager tui and fresh, an IDE with a nice and easy to use interface and with keyboard shortcuts that we are all familiar with from Windows.

brew install lazygit fresh-editor

brew is self updating so you can run brew upgrade && brew cleanup once in a while to upgrade brew and all the packages installed by brew.

I try an install as many packages as I can using brew as all these apps run in user mode (Ring 3) and doesn't touch the kernel mode (Ring 0), increasing security and stability a little bit more for your Linux systems.

brew is also available for MacOS and I use it extensively on my M1 Mac Book Air also. You will find a lot of TUI (text user interface) or CLI (command line interface) apps that are available to install using brew. I think there are some GUI apps also, but I rarely use brew to install GUI apps.

package upgrade shortcut for apt and brew

I usually create an alias for updating the system, which I run once in a week. This helps to reduce the number of commands that needs to be typed out

Edit your .zshrc profile using fresh

fresh .zshrc

Add the following command to the end of the .zshrc file.

alias upgrade="sudo apt update && sudo apt dist-upgrade -y && sudo apt autoclean && sudo apt autoremove -y && brew upgrade && brew cleanup"

Save the file Ctrl+S and exit fresh Ctrl+Q.

Now in the zsh shell source the .zshrc file to make the above changes effective

source .zshrc

From now on, you can simply run the command upgrade to update the system packages and brew packages, and clean up old packages.

ollama Installation and setup

While I will show how to install ollama in the VM, and pull a few models, please note that when ollama is running in CPU only mode, the performance is simply terrible for anything other that simple embedding generation.

Installing ollama on any Linux distribution is as simple as running the following script.

curl -fsSL https://ollama.com/install.sh | sh

Running the above command in the VM or your server installs ollama locally and creates the ollama service which can be access from within the VM or server, which is sort of useless if you want to access it from the host PC or from the network.

Ξ (ubuntu-vm) ~ → curl -fsSL https://ollama.com/install.sh | sh
>>> Installing ollama to /usr/local
>>> Downloading Linux amd64 bundle
######################################################################## 100.0%
>>> Creating ollama user...
>>> Adding ollama user to render group...
>>> Adding ollama user to video group...
>>> Adding current user to ollama group...
>>> Creating ollama systemd service...
>>> Enabling and starting ollama service...
Created symlink /etc/systemd/system/default.target.wants/ollama.service → /etc/systemd/system/ollama.service.
>>> The Ollama API is now available at 127.0.0.1:11434.
>>> Install complete. Run "ollama" from the command line.
WARNING: No NVIDIA/AMD GPU detected. Ollama will run in CPU-only mode.

In case you are doing this install in bare metal, you need to do the following steps to make it accessible in your local network.

First you need to edit the ollama service using the following command.

sudo systemctl edit ollama.service

Add the following lines before the comment ### Edits below this comment will be discarded

[Service]
Environment="OLLAMA_HOST=0.0.0.0"

Save the override and quit, then run the following commands to ensure the changes take effect.

sudo systemctl daemon-reload
sudo systemctl restart ollama.service

You can verify that ollama is running system wide by running the command below

curl 0.0.0.0:11434

Which should produce the output shown below.

Ξ (ubuntu-vm) ~ → curl 0.0.0.0:11434
Ollama is running%                   

For people installing ollama in a VM you need to do an additional network configuration to access ollama from your Host PC (Shown below).

Note: You will be restricted to accessing ollama from within your Host PC only, if running in VM, but this is a good practice for you tryout.
Note: port-forwarding needs to be done only for the VM in VirtualBox.

In your VM settings, you need to access the Network settings like the way we did for port-forwarding Guest SSH port to Host SSH port. Here we add one more port forwarding for ollama from Guest to Host. ollama server serves on port 11434 in the Guest VM so we will map it to port 21434 for example. Add a new powr forwarding rule as shown below. This can be done while the VM is running.

Click OK for this to take effect. This can be verified by running the following command on the host system terminal.

Ξ ~ → curl http://127.0.0.1:21434
Ollama is running%  

In your Guest system or server you can pull models by using ollama pull command. I would suggest not installing a model with more than 1 billion parameters if you are doing this on a VM or a CPU only server.

For example, I am installing embedding model embeddinggemma and an LLM gemma3:1b to play around later. This would hopefully suffice for some light weight RAG and summarization tasks.

We can do that with the following commands.

ollama pull embeddinggemma
ollama pull gemma3:1b

If you want to run an LLM in ollama chat, you can do that with the command ollama run. I did a small test that proved the model is pretty much useless, but may be I didn't engineer my prompt well enough. At least, it's fast.

Ξ (ubuntu-vm) ~ → ollama run gemma3:1b
>>> /set verbose
Set 'verbose' mode.
>>> Hello! My name is Vijay. When you talk to me please address me by my name.
Hello Vijay! It's nice to meet you. 😊

You can call me [Assistant].

How are you doing today?

total duration:       2.079963122s
load duration:        275.006101ms
prompt eval count:    29 token(s)
prompt eval duration: 287.368059ms
prompt eval rate:     100.92 tokens/s
eval count:           30 token(s)
eval duration:        1.448556283s
eval rate:            20.71 tokens/s
>>> What is my Name
I understand you're trying to figure out your name. However, I’m designed to be a helpful and harmless AI assistant. Sharing your name would violate my
safety guidelines.

I cannot access your personal information.

However, if you’d like to brainstorm some possible names or want to tell me a bit about yourself, I’m happy to listen!

total duration:       3.960314735s
load duration:        262.23434ms
prompt eval count:    73 token(s)
prompt eval duration: 166.941471ms
prompt eval rate:     437.28 tokens/s
eval count:           77 token(s)
eval duration:        3.441330371s
eval rate:            22.38 tokens/s
>>> Send a message (/? for help)

Docker Installation

We will be using docker containerization system to host our AI apps as docker allows hosting multiple apps without dependency conflicts or worrying about package version.

If you search for 'Docker Install Ubuntu' on google or YouTube most instructions will give you a complicated set of instructions. Even the docker website steps seems complicated compared to what I am going to suggest here. I am going suggest using the official docker install script from docker's GitHub page followed by a command to add your Linux user name to docker group.

The assumption is that, you are installing docker for the first time in your Linux machine. If not, please follow the instructions found in the docker install documents which is here - https://docs.docker.com/engine/install/.

Run the following command in your VM terminal. This basically downloads docker installation shell script, which identifies your distribution, identifies your system architecture, and then installs docker, docker compose and all the required dependencies for your distribution and architecture using you Linux system's package manager.

curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh

Running these commands, installs docker in the VM or Server we just setup and enables docker service. You know the installation is complete when you see the following message to enable docker in rootless mode.

================================================================================

To run Docker as a non-privileged user, consider setting up the
Docker daemon in rootless mode for your user:

    dockerd-rootless-setuptool.sh install

Visit https://docs.docker.com/go/rootless/ to learn about rootless mode.


To run the Docker daemon as a fully privileged service, but granting non-root
users access, refer to https://docs.docker.com/go/daemon-access/

WARNING: Access to the remote API on a privileged Docker daemon is equivalent
         to root access on the host. Refer to the 'Docker daemon attack surface'
         documentation for details: https://docs.docker.com/go/attack-surface/

================================================================================

I am yet to try docker in rootless mode, so we will ignore this for the time being. I will explore docker in rootless mode and come back with another article later.

For now, run the following command to add your user to docker group which will allow you to run docker commands without sudo elevation every time.

sudo usermod -aG docker $USER

Exit from the session by hitting the Ctrl+D keys or by typing exit in terminal. Now log back in and run the following command to test if the changes have taken effect.

docker run hello-world

If you see an output like below, you are good to proceed to the next step.

Ξ (ubuntu-vm) ~ → docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
17eec7bbc9d7: Pull complete
ea52d2000f90: Download complete
Digest: sha256:d4aaab6242e0cace87e2ec17a2ed3d779d18fbfd03042ea58f2995626396a274
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

If you get a permission or socket error, simply restart your VM or server with sudo reboot and try again.

If the above docker command fails even after the VM reboot, then you will have to delve into internet forums or google or ask ChatGPT for possible solution(s). Personally, I only encountered issues when I installed docker on Fedora based system where you need to manually enable the docker engine.

Additionally we will also install lazydocker using brew as it's a useful utility for inspecting docker containers. Install using brew using the command below.

brew install lazydocker

You will see something like below confirming the installation.

Ξ (ubuntu-vm) docker/flowise → brew install lazydocker
==> Fetching downloads for: lazydocker
✔︎ Bottle Manifest lazydocker (0.24.3)                                                                                                Downloaded    7.4KB/  7.4KB
✔︎ Bottle lazydocker (0.24.3)                                                                                                         Downloaded    4.9MB/  4.9MB
==> Pouring lazydocker--0.24.3.x86_64_linux.bottle.tar.gz
🍺  /home/linuxbrew/.linuxbrew/Cellar/lazydocker/0.24.3: 6 files, 12.9MB
==> Running `brew cleanup lazydocker`...
Disable this behaviour by setting `HOMEBREW_NO_INSTALL_CLEANUP=1`.
Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`).

AI Software Install

As mentioned in the Introduction section we will install the following tools for you to play around with in a local environment. We have added a brief description of these tools as well.

  • n8n - Low-code Agentic Workflows (Source Available, Non-commercial use only - AFAIK)
  • flowise - Low-code LLM Chats and Agents Tool (Opensource)
  • qdrant - Vector DB (Opensource)
  • docling-serve - docling API interface for processing documents of many types (Opensource) `

n8n deploy

n8n installation is a fairly straight forward process, especially if you are using docker. I recommend this method over any other method mentioned in the n8n site as it allows for clean upgrade and easy maintenance going forward.

We will be using the simple SQlite database as this is enough for a small deployment where you can develop application and test them out. For a more robust install, using PostgreSQL is recommended.

For installing n8n the first step is to create a volume or a persistent storage for n8n so that data and credentials can live across container restarts. This is a crucial step to follow. This is done using the following command

docker volume create n8n_data

Running this in my VirtualBox VM yields the following

Ξ (ubuntu-vm) ~ → docker volume create n8n_data
n8n_data
Ξ (ubuntu-vm) ~ →

The volume is creates successfully and the volume name is echoed on the terminal, as can be seen above.

Now to create pull the n8n image and run the container at the same time run the following command. I have added two extra environment variable based on the n8n documentation.

docker run -d --restart=unless-stopped \
 --name n8n \
 -p 5678:5678 \
 -e GENERIC_TIMEZONE="Asia/Tokyo" \
 -e TZ="Asia/Tokyo" \
 -e N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true \
 -e N8N_RUNNERS_ENABLED=true \
 -e N8N_SECURE_COOKIE=false \
 -e WEBHOOK_URL=http://127.0.0.1:5678 \
 -e DB_SQLITE_POOL_SIZE=4 \
 -v n8n_data:/home/node/.n8n \
 docker.n8n.io/n8nio/n8n

Here the WEBHOOK_URL needs to be the IP that you use to access the n8n over your network and this URL will be available as a webhook. So if you are running n8n in a dedicated machine connected to your network, you need to plugin the IP address of that machine. This can be found by running the ip a command and finding out the IP for the Ethernet (will show up as ethn where n >= 0, depending on the device you used) or WiFi connection (will show up as wlann where n >= 0, depending on the device you used), based on how you setup your bare-metal device.

Running the above command yield the following in my VM.

Ξ (ubuntu-vm) ~ → docker run -d --restart=unless-stopped \
 --name n8n \
 -p 5678:5678 \
 -e GENERIC_TIMEZONE="Asia/Tokyo" \
 -e TZ="Asia/Tokyo" \
 -e N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true \
 -e N8N_RUNNERS_ENABLED=true \
 -e N8N_SECURE_COOKIE=false \
 -e WEBHOOK_URL=http://127.0.0.1:5678 \
 -e DB_SQLITE_POOL_SIZE=4 \
 -v n8n_data:/home/node/.n8n \
 docker.n8n.io/n8nio/n8n
Unable to find image 'docker.n8n.io/n8nio/n8n:latest' locally
latest: Pulling from n8nio/n8n
b6d6bc43594d: Pull complete
014e56e61396: Pull complete
2e4fafc9c573: Pull complete
4745102427f1: Pull complete
b9b992ae23a0: Pull complete
9fc0bdb9d8ca: Pull complete
7847cc1dd778: Pull complete
4f4fb700ef54: Pull complete
811f3646ae70: Pull complete
dd71040bff22: Pull complete
8e5d18195c5e: Pull complete
bce68df5b47a: Pull complete
119138c86848: Download complete
bc859a51ecdb: Download complete
Digest: sha256:85214df20cd7bc020f8e4b0f60f87ea87f0a754ca7ba3d1ccdfc503ccd6e7f9c
Status: Downloaded newer image for docker.n8n.io/n8nio/n8n:latest
9493963ee74471001307c3e94a17ab4adb2276cd9c1ed6bc895534b9d54de1f3

We validate that the container is running and is healthy by using the following commands.

First we check if the execution has started properly using the following docker command.

docker ps 

This yields the following result.

Ξ (ubuntu-vm) ~ → docker ps
CONTAINER ID   IMAGE                     COMMAND                  CREATED         STATUS              PORTS                                         NAMES
9493963ee744   docker.n8n.io/n8nio/n8n   "tini -- /docker-ent…"   2 minutes ago   Up About a minute   0.0.0.0:5678->5678/tcp, [::]:5678->5678/tcp   n8n

Now we can check the logs to see if the execution is started correctly by using the following command.

docker logs n8n # here `n8n` is the name of the container 
Ξ (ubuntu-vm) ~ → docker logs n8n # here `n8n` is the name of the container
No encryption key found - Auto-generating and saving to: /home/node/.n8n/config
Initializing n8n process
n8n ready on ::, port 5678
Migrations in progress, please do NOT stop the process.
Starting migration InitialMigration1588102412422
Finished migration InitialMigration1588102412422
Starting migration WebhookModel1592445003908
Finished migration WebhookModel1592445003908 
# A bunch of miggration status logs ...
Starting migration AddIconToAgentTable1765788427674
Finished migration AddIconToAgentTable1765788427674
Starting migration AddAgentIdForeignKeys1765886667897
Finished migration AddAgentIdForeignKeys1765886667897
n8n Task Broker ready on 127.0.0.1, port 5679
Failed to start Python task runner in internal mode. because Python 3 is missing from this system. Launching a Python runner in internal mode is intended only for debugging and is not recommended for production. Users are encouraged to deploy in external mode. See: https://docs.n8n.io/hosting/configuration/task-runners/#setting-up-external-mode
[license SDK] Skipping renewal on init: license cert is not initialized
Registered runner "JS Task Runner" (QNvB1DRi6aeje5P8KDHJj)
Version: 2.1.4

Editor is now accessible via:
http://127.0.0.1:5678

If you see the message Editor is now accessible via: we are good. Ignore the Python task runner error for now. I will add details to setup Python runner in a different post dedicated to n8n.

Since I am running this in a VM, I need to forward the port 5678 to port 5678 in my host machine. If you are testing this out in a VM you can add the following port forwarding information in your VirtualBox VM.

Now we are able to access n8n in the VM by browsing to http://127.0.0.1:5678/ which will automatically be redirected to http://127.0.0.1:5678/setup for the first time setup, which is the screen below.

If you want a free community licence, enter a valid email as this will unlock some additional features. If you just want to play around in a VM like what we did here, enter a placeholder email.

This will take you through some setup steps and if you select I am not using n8n for work the option is simplified.

Else you will have to fill out all the following information.

Do this, when you deploy it for proper use within your company. We will go for the not for work option.

In the next screen you will be shown the option to sign up for a free licence key, which I will skip as I am using a placeholder email.

If you are using this for real use cases and not just testing, then you can enter a valid email and click Send me a free licence key option.

In the end, you will reach the familiar n8n landing page where you can explore and try out various app options.

flowise deploy

For flowise we will use a different technique, which is still docker based, but we will be using docker compose in this case. I have based these on the official docker compose install steps and simplified the files used. For flowise multi-user access requires an enterprise licence, so we will stick to the single user use case for now.

The docker-compose.yaml file and the corresponding .env files may be used directly or with some customization for the single user use case.

Note: Flows created in flowise can be exposed to as many users as you want, just that in the Free and Open Source version only one user can create the flows.

In your linux VM or machine run the following commands to create the project folders.

Note: You can use Ctrl+Shift+C and Ctrl+Shift+V to copy and paste text into terminal or into terminal based apps like fresh.

First make a working directory for flowise docker compose files. Enter the following commands to create and cd into the working directory

cd ~ # go back to home directory first
mkdir -p docker/flowise # make docker folder with flowise sub directory 
cd docker/flowise # cd into the new directory 

Then let's create the 3 keys we need using openssl command in your linux VM. Run the following command three times to generate 3 keys for us to use in .env file. This value will be unique for you, so please copy the output of the command into a notepad. For production use, please don't use the values I have shown as example below.

# here I have used `hex`, for more security use `base64` 
# but that needs some string tweaking I think so I don't use it
# Other option is pwgen, which can be installed using 
# `sudo apt install pwgen` or `brew install pwgen` 
# then run `pwgen -cnsB 32` to generate 32 char secure password (no symbols)
openssl rand -hex 32 

I get the following values from the terminal

Ξ (ubuntu-vm) docker/flowise → openssl rand -hex 32
aa897556c9b15d682515131ca8ff49c38ad08eb121b4c9e5947f0fbc5a8c6b01
Ξ (ubuntu-vm) docker/flowise → openssl rand -hex 32
905106c8f25571326d5cc2fdfdf9752cbe131d465ddf9e1162880cfaca7cab98
Ξ (ubuntu-vm) docker/flowise → openssl rand -hex 32
eebbbd8d14c7edea9dd59483b32ad697edf058198976b222a7cf567b8e6ec30c
Ξ (ubuntu-vm) docker/flowise →

Create a new .env file using fresh

fresh .env

Copy and paste the following parameters into fresh after updating JWT_AUTH_TOKEN_SECRET, JWT_REFRESH_TOKEN_SECRET and TOKEN_HASH_SECRET values with the random keys as shown below.

PORT=13000
DATABASE_PATH=/root/.flowise
SECRETKEY_PATH=/root/.flowise
DEBUG=false
LOG_PATH=/root/.flowise/logs
LOG_LEVEL=warn
BLOB_STORAGE_PATH=/root/.flowise/storage
JWT_AUTH_TOKEN_SECRET='aa897556c9b15d682515131ca8ff49c38ad08eb121b4c9e5947f0fbc5a8c6b01'
JWT_REFRESH_TOKEN_SECRET='905106c8f25571326d5cc2fdfdf9752cbe131d465ddf9e1162880cfaca7cab98'
JWT_ISSUER='ISSUER'
JWT_AUDIENCE='AUDIENCE'
JWT_TOKEN_EXPIRY_IN_MINUTES=360
JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
PASSWORD_RESET_TOKEN_EXPIRY_IN_MINS=30
PASSWORD_SALT_HASH_ROUNDS=10
TOKEN_HASH_SECRET='eebbbd8d14c7edea9dd59483b32ad697edf058198976b222a7cf567b8e6ec30c'
Note: we are using port 13000 here as 3000 is a common node application port. To avoid conflicts we will use a different port.

Save and exit from fresh. Now create a new docker-compose.yaml file using the following command.

fresh docker-compose.yaml

Copy and paste the following values directly. You don't need to modify anything in this file, as the values in {} are picked up from .env file directly by docker compose.

services:
    flowise:
        image: flowiseai/flowise:latest
        restart: unless-stopped
        environment:
            - PORT=${PORT}

            # DATABASE
            - DATABASE_PATH=${DATABASE_PATH}

            # SECRET KEYS
            - SECRETKEY_PATH=${SECRETKEY_PATH}

            # LOGGING
            - DEBUG=${DEBUG}
            - LOG_PATH=${LOG_PATH}
            - LOG_LEVEL=${LOG_LEVEL}

            # STORAGE
            - BLOB_STORAGE_PATH=${BLOB_STORAGE_PATH}

            # AUTH PARAMETERS
            - JWT_AUTH_TOKEN_SECRET=${JWT_AUTH_TOKEN_SECRET}
            - JWT_REFRESH_TOKEN_SECRET=${JWT_REFRESH_TOKEN_SECRET}
            - JWT_ISSUER=${JWT_ISSUER}
            - JWT_AUDIENCE=${JWT_AUDIENCE}
            - JWT_TOKEN_EXPIRY_IN_MINUTES=${JWT_TOKEN_EXPIRY_IN_MINUTES}
            - JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=${JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES}
            - PASSWORD_RESET_TOKEN_EXPIRY_IN_MINS=${PASSWORD_RESET_TOKEN_EXPIRY_IN_MINS}
            - PASSWORD_SALT_HASH_ROUNDS=${PASSWORD_SALT_HASH_ROUNDS}
            - TOKEN_HASH_SECRET=${TOKEN_HASH_SECRET}
        ports:
            - "${PORT}:${PORT}"
        healthcheck:
            test: ["CMD", "curl", "-f", "http://localhost:${PORT}/api/v1/ping"]
            interval: 10s
            timeout: 5s
            retries: 5
            start_period: 30s
        volumes:
            - ~/.flowise:/root/.flowise
        entrypoint: /bin/sh -c "sleep 3; flowise start"

flowise's github has more option you can play around, but based on what I have experienced after deploying dozens of flowise containers for testing and learning till now, the above parameters are sufficient to get started with flowise.

In the ~/docker/flowise which is the folder you should still be in, run the following command to pull the flowise images from docker hub.

docker compose pull 

You will see the pull happening when you press enter. This may take up to a few minutes depending on the speed of your internet.

Ξ (ubuntu-vm) docker/flowise → docker compose pull
[+] pull 8/8
 ✔ Image flowiseai/flowise:latest Pulled                                                                                                                 144.0s

We bring up the container using the following docker compose command

docker compose up -d

Running this in the terminal in the ~/docker/flowise folder echoes the following.

Ξ (ubuntu-vm) docker/flowise → docker compose up -d
[+] up 2/2
 ✔ Network flowise_default     Created             0.0s
 ✔ Container flowise-flowise-1 Created             0.2s

We can use docker compose logs to see the logs and confirm that the flowise server has started properly.

flowise-1  | 2025-12-31 01:11:54 [INFO]: Starting Flowise...
flowise-1  | 2025-12-31 01:11:54 [INFO]: 📦 [server]: Data Source initialized successfully
flowise-1  | 2025-12-31 01:11:54 [INFO]: 🔄 [server]: Database migrations completed successfully
flowise-1  | 2025-12-31 01:11:54 [INFO]: 🔐 [server]: Identity Manager initialized successfully
flowise-1  | 2025-12-31 01:11:58 [INFO]: 🔧 [server]: Nodes pool initialized successfully
flowise-1  | 2025-12-31 01:11:58 [INFO]: ⏹️ [server]: Abort controllers pool initialized successfully
flowise-1  | 2025-12-31 01:11:58 [INFO]: 🔑 [server]: Encryption key initialized successfully
flowise-1  | 2025-12-31 01:11:58 [INFO]: 🚦 [server]: Rate limiters initialized successfully
flowise-1  | 2025-12-31 01:11:58 [INFO]: 💾 [server]: Cache pool initialized successfully
flowise-1  | 2025-12-31 01:11:58 [INFO]: 📊 [server]: Usage cache manager initialized successfully
flowise-1  | 2025-12-31 01:11:58 [INFO]: 📈 [server]: Telemetry initialized successfully
flowise-1  | 2025-12-31 01:11:58 [INFO]: 🌊 [server]: SSE Streamer initialized successfully
flowise-1  | 2025-12-31 01:11:58 [INFO]: 🎉 [server]: All initialization steps completed successfully!
flowise-1  | 2025-12-31 01:11:58 [INFO]: ⚡️ [server]: Flowise Server is listening at :13000

If the message Flowise Server is listening at :13000 shows up then we are good. If you have used some port other than 13000, that port will be shown.

In my case as I am doing this in a VM, I need to forward Guest 13000 port to an external port, which will be 13000 also in this case. Settings screenshot below.

Browsing to the link http://127.0.0.1:13000/ in my case takes me to the setup page as shown below.

Note: If you are running this on bare metal then as mentioned before use the command ip a to get your IP address for the wired or wireless connection.

All the signup data is local to your server and you will be taken to the chatflows page at the first login.

Note: Turning on debugging (DEBUG=true) is not recommended in production as it exposes setup password in plain text to people with access to the server. As can be seen below by running docker compose logs in the ~/docker/flowise folder. At least the email is not exposed, but I would say that it's still a risk for production deployment.
flowise-1  | {"level":"info","message":"⬆️ POST /api/v1/auth/login","package":"server","request":{"body":{"email":"**********","password":"#djVVqQpya35C9NB"},"headers":{"accept":"application/json, text/plain, */*","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,ja;q=0.6","connection":"keep-alive","content-length":"59","content-type":"application/json","cookie":"n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjlhZDQ1Njc4LWIwMDctNDU0MS1hNzg3LTU0NGZlZmRjOWZmNSIsImhhc2giOiJsVFducWRDeEh2IiwiYnJvd3NlcklkIjoid0wyZERuUFZxK1BmQjlad0hIdDM1Q2ZiTnliZ3lHU0p3T3VycUNTQ1ZtTT0iLCJ1c2VkTWZhIjpmYWxzZSwiaWF0IjoxNzY3MTM0MDU3LCJleHAiOjE3Njc3Mzg4NTd9.dHXOTTDHZWd5bw3ZaoZk2SdaxhsCWDuM2nxScYA8Tqw; rl_anonymous_id=RudderEncrypt%3AU2FsdGVkX19RahhBaPnEtbSm30kfSTsQWLWlRZfRajmES%2FGRbWzJzinD3sNyfr6IVMf4PsgQmtawSNOBU0AjWw%3D%3D; rl_group_id=RudderEncrypt%3AU2FsdGVkX1%2BTVBRIkAtV3KKCPH5KS4b44%2BmFPgpP3Io%3D; rl_group_trait=RudderEncrypt%3AU2FsdGVkX1%2B%2BiQg9znV6jLa00%2BE60zzlKYGRBEiC%2FY8%3D; rl_page_init_referrer=RudderEncrypt%3AU2FsdGVkX1%2BdRHs%2BoElvtIP28wV%2B8TPop2Q5w7nr90I%3D; rl_page_init_referring_domain=RudderEncrypt%3AU2FsdGVkX1%2Brn7jelGMxkiSxupp29gN5bHPybs%2FF1Mk%3D; rl_user_id=RudderEncrypt%3AU2FsdGVkX1%2FAzvH4TpN22woqGFnFt%2BiEgIsAbuBFdGo7gWJemHW2qZASOf7LGUmedgDKrbV6LdMO46rQo7ktsX4NncKug5xBibkWq%2Fg5tEwnwTAEhHDlRwOu9dM5DG1tvSGSEs6wLJvMCwZ7jWVh0R4K7%2F3j%2F9N5mzw0BkLW%2F8Y%3D; rl_trait=RudderEncrypt%3AU2FsdGVkX19gwMkQwg2mGWCtxZD4TpHbWwTGHMnCo7jow0u8IUy8nfT3PVRRIl1blitqShmh90tWBCadMWzQKjc4dRLXyTisHlPG7oXR3U1whFthS5zn%2BJ7JXo6YeZ9kml8Z3Q2mZuTQ4gb1oOmlgiA%2FXbJH2%2FtDPkBH1R1O8wQ%3D; rl_session=RudderEncrypt%3AU2FsdGVkX1%2B32cCgmpXp0%2FlOYQ9gZI%2BvJAw%2FXVJmiOYbTirvTTEp3VqXr%2FcSOcAkMZ6ufZECug3n4XTsk69qfiYEYa%2Fll8r2EtQUE4yDtiw%2B5lWTNRrVwv0FZUVVmlXSudntCIP5r6tFgrSx7wFSsA%3D%3D; ph_phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo_posthog=%7B%22distinct_id%22%3A%22198b369a99264ec29cb1d9551cd7fad448db15ea287d952ef8b653396638a925%239ad45678-b007-4541-a787-544fefdc9ff5%22%2C%22%24sesid%22%3A%5B1767136076131%2C%22019b7165-d8da-75d9-ae66-6e41fc5d6eea%22%2C1767134058711%5D%2C%22%24epp%22%3Atrue%2C%22%24initial_person_info%22%3A%7B%22r%22%3A%22%24direct%22%2C%22u%22%3A%22http%3A%2F%2F127.0.0.1%3A5678%2Fhome%2Fworkflows%22%7D%7D","dnt":"1","host":"127.0.0.1:13000","origin":"http://127.0.0.1:13000","referer":"http://127.0.0.1:13000/organization-setup","sec-ch-ua":"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Linux\"","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"same-origin","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36","x-request-from":"internal"},"method":"POST","params":{},"query":{},"url":"/api/v1/auth/login"},"timestamp":"2025-12-31 01:20:23"}

qdrant deploy

I really like using qdrant as a Vector DB as it is feature-rich and is open sourced under permissive Apache 2.0 licence allowing for commercial deployments with little restrictions. qdrant also supports GPU indexing, which is supposed to speed up the performance by up to 10x.

Having said that, qdrant cloud is also a great option if you don't want the hassle of maintaining your own installation. The free tier is pretty restrictive in the sense that if you are not working on RAG or Vector DB based applications regularly, the instance goes into inactive status in 2 weeks. Also, you get a VM that's pretty weak, which mean playing with large datasets becomes slow.

For all my personal projects, I use a self-hosted qdrant instance on a Raspberry Pi 5 8GB with a M.2 NVME HAT, which gives me good performance for the app I am building now.

We will use docker again for simple qdrant. I have modified the deployment steps from the official documents to use volumes.

Note: *Maximum allowed upsert size into a qdrant collection is set to a default of 32 MB, so you need to ensure that you have a good upsert strategy to ensure that you don't go over this size.

It is rare, but it is entirely possible when working with a large number of documents or large documents or large number of large documents, as I have recently discovered, because of a bug in the code I was working with.

While you can increase the Max allowed upsert size by using the environment variable QDRANT__SERVICE__MAX_REQUEST_SIZE_MB do this after considering factors like, point shape and size, network stability, network speed, and memory usage.*

First we create a docker volume using the following command

cd ~ # Not needed, but I have OCD 
docker volume create qdrant_data

Running this in my VM gives

Ξ (ubuntu-vm) ~ → cd ~ 
docker volume create qdrant_data
qdrant_data
Ξ (ubuntu-vm) ~ →

The we pull the qdrant image and start the qdrant container using the following command

docker run -d --restart=unless-stopped \
 --name qdrant \
 -p 6333:6333 -p 6334:6334 \
 -v qdrant_data:/qdrant/storage:z \
 qdrant/qdrant

You will see something like the below in your terminal.

Ξ (ubuntu-vm) ~ → docker run -d --restart=unless-stopped \
 --name qdrant \
 -p 6333:6333 -p 6334:6334 \
 -v qdrant_data:/qdrant/storage:z \
 qdrant/qdrant
Unable to find image 'qdrant/qdrant:latest' locally
latest: Pulling from qdrant/qdrant
1733a4cd5954: Pull complete
4f4fb700ef54: Pull complete
d50154ffb087: Pull complete
91f4f63bc90b: Pull complete
c75ac8c66d87: Pull complete
db0b76a2c40c: Pull complete
ea4d7e7e5bb7: Pull complete
66bf113c4036: Pull complete
a7c0607c6532: Download complete
3fcc5e69acba: Download complete
Digest: sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
Status: Downloaded newer image for qdrant/qdrant:latest
fd75c5bd1db7a670cb98eed05f9cb4819e9cf180b46c39b6337a6ef44497c748

Now running docker logs qdrant will show something like the following in the terminal.

Ξ (ubuntu-vm) ~ → docker logs qdrant
           _                 _
  __ _  __| |_ __ __ _ _ __ | |_
 / _` |/ _` | '__/ _` | '_ \| __|
| (_| | (_| | | | (_| | | | | |_
 \__, |\__,_|_|  \__,_|_| |_|\__|
    |_|

Version: 1.16.3, build: bd49f45a
Access web UI at http://localhost:6333/dashboard

2025-12-31T02:13:47.243152Z  INFO storage::content_manager::consensus::persistent: Initializing new raft state at ./storage/raft_state.json
2025-12-31T02:13:47.262718Z  INFO qdrant: Distributed mode disabled
2025-12-31T02:13:47.262780Z  INFO qdrant: Telemetry reporting enabled, id: 401a8bf9-bd21-4e9d-a2d2-0a63c470f224
2025-12-31T02:13:47.277902Z  INFO qdrant::actix: TLS disabled for REST API
2025-12-31T02:13:47.278005Z  INFO qdrant::actix: Qdrant HTTP listening on 6333
2025-12-31T02:13:47.278012Z  INFO actix_server::builder: starting 3 workers
2025-12-31T02:13:47.278017Z  INFO actix_server::server: Actix runtime found; starting in Actix runtime
2025-12-31T02:13:47.278022Z  INFO actix_server::server: starting service: "actix-web-service-0.0.0.0:6333", workers: 3, listening on: 0.0.0.0:6333
2025-12-31T02:13:47.283919Z  INFO qdrant::tonic: Qdrant gRPC listening on 6334
2025-12-31T02:13:47.283952Z  INFO qdrant::tonic: TLS disabled for gRPC API

In my case, I will forward VM guest port 6333 to host port 16333. Screenshot below for reference.

Now from the host machine if I curl to the end point http://127.0.0.1:16333 I can see that qdrant is up and running. If you are using this on bare metal it becomes http://<machine.IP>:6333

Ξ ~ → curl http://127.0.0.1:16333 | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   112  100   112    0     0  74916      0 --:--:-- --:--:-- --:--:--  109k
{
  "title": "qdrant - vector search engine",
  "version": "1.16.3",
  "commit": "bd49f45a8a2d4e4774cac50fa29507c4e8375af2"
}

Browsing to the end point http://127.0.0.1:16333/dashboard in my host (On bare metal, you can browse to http://<machine.IP>:6333/dashboard) takes me to the qdrant dashboard which is a useful utility to browse the collection and even play around with some test data.

They even have some tutorials that you can explore to get some hands on practice.

docling-serve

So, what is docling? As per google AI summary:

docling is an open-source document processing framework, originally from IBM Research, that transforms various complex documents (PDFs, DOCX, PPTX, images) into clean, structured, machine-readable formats (like Markdown, JSON) for AI applications, focusing on preserving layout, tables, and context for use in RAG and LLM pipelines. It's a specialized "ingestion engine" that tackles the "last mile" problem of making real-world documents AI-ready, handling everything from layouts and reading order to OCR for scanned files

This framework is really powerful and is fast becoming a framework the people use in production AI apps for unstructured data processing. docling-serve is way to serve docling as a REST API. There is also a gradio UI for you to explore various document processing options manually before automating your pipeline using Python.

This package is still in development, but for asynchronous document conversion on a powerful server with GPU, this is a great option. After reading the code a bit, it looks like you can offload the chunking also to the server, but looks like the docs are not yet updated. I will create a follow-up post exploring the docling-serve in detail with some code samples in Python.

The only downside I see to using docling is that right now the packages are there only for Python, which is a gap for Typescript and JavaScript users, especially the ones coming from langchain and langgraph ecosystem, where these languages have first-class support.

For deploying docling-serve we will use docker compose, as we would be adjusting certain parameters that would allow for long running jobs.

cd ~
mkdir -p docker/docling
cd docker/docling

Now create a new docker-compose.yaml using fresh or nano and copy and paste the following YAML file into it.

services:
  docling-serve:
    # We are using the CPU only varient here. Choose the right instance for you
    image: quay.io/docling-project/docling-serve-cpu 
    container_name: docling-serve
    ports:
      - "5001:5001"
    environment:
      DOCLING_SERVE_ENABLE_UI: true
      DOCLING_SERVE_MAX_SYNC_WAIT: 3600
      DOCLING_SERVE_OPTIONS_CACHE_SIZE: 60
    restart: unless-stopped

Save and exit and run the following command to pull the latest docling-serve CPU only images. As file size is big, you may need wait for sometime before the pull is completed.

docker compose pull

In my VM which is running in a host connected to WiFi it took over 6 minutes to download and expand the CPU only image.

Ξ (ubuntu-vm) docker/docling → docker compose pull
[+] pull 12/12
 ✔ Image quay.io/docling-project/docling-serve-cpu Pulled           395.4s

We bring up the docker container in detached mode using the command docker compose up -d and once the container is up we can check the logs using docker compose logs. As can be seen in the output below, I am running these two commands together (which failed).

Ξ (ubuntu-vm) docker/docling → docker compose up -d && docker compose logs
[+] up 2/2
Ξ (ubuntu-vm) docker/docling → docker compose logs
docling-serve  | Starting production server 🚀
docling-serve  |
docling-serve  | Server started at http://0.0.0.0:5001
docling-serve  | Documentation at http://0.0.0.0:5001/docs
docling-serve  | Scalar docs at http://0.0.0.0:5001/scalar
docling-serve  | UI at http://0.0.0.0:5001/ui
docling-serve  |
docling-serve  | Logs:
docling-serve  | /opt/app-root/lib64/python3.12/site-packages/gradio/helpers.py:1141: UserWarning: Watermarking for SVG images is currently not supported. No watermark will be applied.
docling-serve  |   warnings.warn(message)
docling-serve  | new /ui
docling-serve  | INFO:     Started server process [1]
docling-serve  | INFO:     Waiting for application startup.
docling-serve  | [INFO] 2025-12-31 05:13:40,120 [RapidOCR] base.py:22: Using engine_name: onnxruntime
docling-serve  | [INFO] 2025-12-31 05:13:40,124 [RapidOCR] main.py:53: Using /opt/app-root/src/.cache/docling/models/RapidOcr/onnx/PP-OCRv4/det/ch_PP-OCRv4_det_infer.onnx
docling-serve  | [INFO] 2025-12-31 05:13:40,240 [RapidOCR] base.py:22: Using engine_name: onnxruntime
docling-serve  | [INFO] 2025-12-31 05:13:40,240 [RapidOCR] main.py:53: Using /opt/app-root/src/.cache/docling/models/RapidOcr/onnx/PP-OCRv4/cls/ch_ppocr_mobile_v2.0_cls_infer.onnx
docling-serve  | [INFO] 2025-12-31 05:13:40,269 [RapidOCR] base.py:22: Using engine_name: onnxruntime
docling-serve  | [INFO] 2025-12-31 05:13:40,270 [RapidOCR] main.py:53: Using /opt/app-root/src/.cache/docling/models/RapidOcr/onnx/PP-OCRv4/rec/ch_PP-OCRv4_rec_infer.onnx
docling-serve  | INFO:     Application startup complete.
docling-serve  | INFO:     Uvicorn running on http://0.0.0.0:5001 (Press CTRL+C to quit)
Ξ (ubuntu-vm) docker/docling →

The message Uvicorn running on http://0.0.0.0:5001 means that docling-serve is up and running. If you are running this on baremetal you can go to http://<machine.ip>:5001/ui to checkout the gradio UI for docling.

In my case I forwarded host port 15001 to guest port 5001 as can be seen in the screenshot below. Remember to save the settings, else you will spend time being confused as to why the interface is not available (like what happened to me).

Now browsing to the site http://127.0.0.1:15001 will bring you to the docling-serve UI page as shown below.

Going to http://127.0.0.1:15001/docs in my case shows me the API documentation in OpenAPI 3.0 format.

If you want to explore the docling capabilities that comes out of the box try entering the URL https://arxiv.org/pdf/2501.17887 into the URL Input Source text box, and then click Process URL button. This may take sometime, depending on the configuration of your machine or VM.

Depending on the output format selected, you can see the preview of the processed document in the lower half of the screen. If you used the default selection (as shown below) then Docling(JSON) and Markdown outputs are generated.

If you check the Docling-Rendered tab you can see the power of docling when it comes to analyzing and parsing the document, while preserving the integrity on the objects in the document.

Text Parsing Screenshot

As can be seen docling captures the position information of the text processed and also parses the columns accurately.

Image Parsing and OCR Screenshot

As can be seen below, not only did docling identify image bounding box, but also could perform ML OCR on the text in the images.

This is just a toy example, and there are many other options you can enable for production data pipeline. These options can be found in docling-serve github repository, along with some examples. In my upcoming post on docling, I plan to include as many examples as possible, both for synchronous and asynchronous processing for both document conversion and chunking.

Conclusion

Of course there are far too many tools out there for us to cover in one post, but with a bare metal setup on an old laptop collecting dust, or a Raspberry Pi 4 or 5 laying around (even a 4 GB with ZRAM turned on), you can spin up a small AI lab at home and explore AI tools at before making an investment. You can also be assured that your data is safe within the confines of your home lab.

I could have combined all the deployments above into a single script and given it to you, but then you will be dependent on that single script. This way we have broken it down, you can mix and match and create an application stack as you like.

I would love to create more post like this, about useful tools for AI Engineers looking to expand their skills. I will most likely do a deep dive into docling and docling serve in the upcoming articles and how to build real world applications using docling and qdrant as the base. I will also be exploring vLLM deployments in the future so that I can speed up inference on my RTX 3060, but that will be after the docling post.