Sonic is a small, schema-less search backend written in Rust. Instead of storing documents, it indexes text and returns the identifiers of matching objects, which your application then loads from its own database. It runs comfortably in a few dozen megabytes of RAM, which makes it a good fit for adding search to an existing app on a small server. In this tutorial you will install Sonic on Ubuntu 24.04 as a systemd service, index and query text with its TCP protocol, enable autocomplete, and use it from a Node.js application.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS (x86_64), for example a CubePath VPS. 512 MB of RAM is enough for small indexes.
  • A non-root user with sudo privileges.
  • Optional, for the last step: Node.js and npm (sudo apt install nodejs npm).

How Sonic organises data

Sonic has no HTTP API and no schema. Everything goes through a line-based TCP protocol called Sonic Channel, on port 1491. Data is addressed in three levels:

LevelMeaningExample
CollectionWhat you search inproducts, messages
BucketA partition inside a collection, often per user or tenantdefault, user:42
ObjectThe ID of the record in your own databaseproduct:17

A connection starts in one of three modes: ingest to add and remove text, search to query and autocomplete, and control for administration.

Step 1 - Installing Sonic

Download the prebuilt Linux release from GitHub. This guide uses v1.10.0; check the releases page for the current version:

cd /tmp
curl -fLO https://github.com/valeriansaliou/sonic/releases/download/v1.10.0/v1.10.0-x86_64-gnu.tar.gz
tar xzf v1.10.0-x86_64-gnu.tar.gz

The archive contains the sonic binary and a sample config.cfg. Install the binary and copy the sample configuration to /etc/sonic:

sudo install -m 0755 sonic/sonic /usr/local/bin/sonic
sudo install -d -m 0755 /etc/sonic
sudo install -m 0640 sonic/config.cfg /etc/sonic/config.cfg

Create a system user for the service and the directories for the key-value store and the autocomplete graph:

sudo useradd --system --home-dir /var/lib/sonic --shell /usr/sbin/nologin sonic
sudo install -d -o sonic -g sonic -m 0750 /var/lib/sonic /var/lib/sonic/store /var/lib/sonic/store/kv /var/lib/sonic/store/fst
sudo chown root:sonic /etc/sonic/config.cfg

Confirm the binary runs on this system:

sonic --help

The command prints the available options, including -c to pass the configuration file.

Step 2 - Configuring Sonic

Generate a password for the channel. Every client must send it when it opens a connection:

openssl rand -hex 24

Open the configuration file:

sudo nano /etc/sonic/config.cfg

Change only the values below and leave the rest of the file as shipped. Set log_level to info, bind the channel to IPv4 localhost, replace your_channel_password with the value you generated, and point both stores to /var/lib/sonic:

[server]
log_level = "info"

[channel]
inet = "127.0.0.1:1491"
tcp_timeout = 300
auth_password = "your_channel_password"

Further down, in the [store.kv] and [store.fst] sections, change the two path lines:

[store.kv]
path = "/var/lib/sonic/store/kv/"

[store.fst]
path = "/var/lib/sonic/store/fst/"

The sample file ships with inet = "[::1]:1491" and relative ./data paths; the changes above make the service independent of its working directory and reachable at 127.0.0.1. Keep Sonic on localhost: the protocol is not encrypted, so remote applications should reach it through a private network or an SSH tunnel.

Step 3 - Running Sonic as a systemd service

Create a unit file:

sudo nano /etc/systemd/system/sonic.service
[Unit]
Description=Sonic search backend
After=network.target

[Service]
Type=simple
User=sonic
Group=sonic
WorkingDirectory=/var/lib/sonic
ExecStart=/usr/local/bin/sonic -c /etc/sonic/config.cfg
Restart=on-failure
RestartSec=5
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now sonic
sudo systemctl status sonic
● sonic.service - Sonic search backend
     Loaded: loaded (/etc/systemd/system/sonic.service; enabled; preset: enabled)
     Active: active (running) since ...

Check that it listens on port 1491 on localhost only:

sudo ss -tlnp | grep 1491
LISTEN 0      1024       127.0.0.1:1491       0.0.0.0:*    users:(("sonic",pid=3120,fd=9))

Step 4 - Indexing text with the ingest channel

You can talk to Sonic by hand with nc (netcat), which Ubuntu installs by default. This is the quickest way to learn the protocol and to debug. Open a connection:

nc 127.0.0.1 1491

Sonic greets you. Type the commands that follow, one per line, replacing your_channel_password:

CONNECTED <sonic-server v1.10.0>
START ingest your_channel_password
STARTED ingest protocol(1) buffer(20000)
PUSH products default product:1 "Mechanical keyboard with hot-swappable switches"
OK
PUSH products default product:2 "Ergonomic vertical mouse for wrist comfort"
OK
PUSH products default product:3 "Wireless keyboard and mouse combo with silent keys"
OK
PUSH products default product:4 "Mechanical pencil with metal body"
OK
QUIT
ENDED quit

Each PUSH has the form PUSH <collection> <bucket> <object> "<text>". The text must be wrapped in double quotes; escape quotes inside it as \" and replace newlines with spaces, since each command is a single line.

Other ingest commands you will use:

  • POP <collection> <bucket> <object> "<text>" removes those words from an object.
  • FLUSHO <collection> <bucket> <object> removes an object entirely, for example when the record is deleted from your database.
  • FLUSHB <collection> <bucket> and FLUSHC <collection> empty a bucket or a whole collection.

Sonic has no update command: to change an object, flush it with FLUSHO and push the new text.

Step 5 - Searching and autocompleting

Open a new connection in search mode:

nc 127.0.0.1 1491
CONNECTED <sonic-server v1.10.0>
START search your_channel_password
STARTED search protocol(1) buffer(20000)
QUERY products default "keyboard" LIMIT(10)
PENDING 8vPx3aHd
EVENT QUERY 8vPx3aHd product:3 product:1
QUERY products default "mechanical keyboard" LIMIT(10)
PENDING nR2kq0Tw
EVENT QUERY nR2kq0Tw product:1
QUIT
ENDED quit

Queries are asynchronous: Sonic answers PENDING with a marker, then sends an EVENT line with the same marker and the matching object IDs. In the second query every word must match, so "mechanical" rules out product 3 and "keyboard" rules out the pencil. LIMIT() and OFFSET() paginate the results. Once the word graph described below has been consolidated, Sonic also tries close alternatives for misspelled words.

Autocomplete uses SUGGEST, which completes a partial word. It reads from a word graph that Sonic rebuilds in the background (by default 180 seconds after the last write). To rebuild it now, open a control connection and trigger a consolidation:

nc 127.0.0.1 1491
CONNECTED <sonic-server v1.10.0>
START control your_channel_password
STARTED control protocol(1) buffer(20000)
TRIGGER consolidate
OK
QUIT
ENDED quit

Now suggest completions for "mech" in a search connection:

START search your_channel_password
STARTED search protocol(1) buffer(20000)
SUGGEST products default "mech" LIMIT(5)
PENDING Qw81bXcE
EVENT SUGGEST Qw81bXcE mechanical
QUIT
ENDED quit

Step 6 - Using Sonic from a Node.js application

In an application you use a client library instead of nc. The officially maintained library for Node.js is sonic-channel. Create a small project:

mkdir ~/sonic-demo && cd ~/sonic-demo
npm init -y
npm install sonic-channel

Create a script that indexes two records and then searches them:

nano demo.js
const { Ingest, Search } = require("sonic-channel");

const options = {
  host: "127.0.0.1",
  port: 1491,
  auth: process.env.SONIC_PASSWORD,
};

const ingest = new Ingest(options).connect({
  connected: async () => {
    await ingest.push("articles", "default", "article:1", "PostgreSQL backup and restore guide");
    await ingest.push("articles", "default", "article:2", "Tuning PostgreSQL memory settings");
    await ingest.close();

    const search = new Search(options).connect({
      connected: async () => {
        const ids = await search.query("articles", "default", "postgresql backup");
        console.log("Matching IDs:", ids);
        await search.close();
      },
      error: (err) => console.error("Search connection failed:", err),
    });
  },
  error: (err) => console.error("Ingest connection failed:", err),
});

Run it, passing the channel password through an environment variable rather than hard-coding it:

SONIC_PASSWORD='your_channel_password' node demo.js
Matching IDs: [ 'article:1' ]

Your application then loads article:1 from its main database. A common pattern is to push text to Sonic whenever a record is created or updated, and to call flusho when it is deleted, so the index follows your data.

Troubleshooting

  • The service fails to start: run sudo journalctl -u sonic -n 50. Wrong ownership of /var/lib/sonic/store and syntax errors in config.cfg are the usual causes.
  • The connection is closed right after START: the password does not match auth_password in the configuration.
  • ERR invalid_format(...): the command is malformed, most often because the text in PUSH or QUERY is not wrapped in double quotes.
  • SUGGEST returns nothing: the word graph has not been consolidated yet. Wait a few minutes or run TRIGGER consolidate in a control connection.
  • Connection refused: check inet in /etc/sonic/config.cfg. The sample file listens on [::1], so a client connecting to 127.0.0.1 fails unless you changed it as in Step 2.

Conclusion

You installed Sonic on Ubuntu 24.04 as a hardened systemd service, indexed text through the ingest channel, ran queries with typo tolerance, enabled autocomplete, and connected to it from Node.js. Next, write a one-off job that pushes your existing records into Sonic, hook pushes and flushes into your application's create, update and delete paths, and back up the index with TRIGGER backup <path> in a control connection. If you later need document storage, filtering or facets, look at a full search engine such as Meilisearch or Typesense.