ZincSearch is a full-text search engine written in Go that ships as a single binary with a built-in web UI and an Elasticsearch-compatible API for ingestion and search. It uses a fraction of the memory Elasticsearch needs, which makes it a good fit for adding search to an application on a small server. In this tutorial you will install ZincSearch on Ubuntu 24.04, run it as a hardened systemd service, create an index, load and query documents, add a restricted application user, and publish the API over HTTPS with Nginx.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS (x86_64), for example a CubePath VPS, with at least 1 GB of RAM.
  • A non-root user with sudo privileges.
  • A domain name with a DNS A record pointing to your server, for example search.your_domain, if you want to complete Step 8 (HTTPS).
  • curl installed (sudo apt install curl).

Step 1 - Downloading the ZincSearch binary

ZincSearch is distributed as a prebuilt binary on its GitHub releases page. This guide uses version 0.4.10, the latest stable 0.4 release. Newer 1.0.0-beta builds exist; check the releases page and only switch once 1.0 is final.

Download the archive and the checksum file into a temporary directory:

cd /tmp
ZINC_VERSION=0.4.10
curl -fLO "https://github.com/zincsearch/zincsearch/releases/download/v${ZINC_VERSION}/zincsearch_${ZINC_VERSION}_linux_x86_64.tar.gz"
curl -fLO "https://github.com/zincsearch/zincsearch/releases/download/v${ZINC_VERSION}/checksums.txt"

Verify the archive against the published checksum:

sha256sum --ignore-missing -c checksums.txt
zincsearch_0.4.10_linux_x86_64.tar.gz: OK

Extract the binary and install it into /usr/local/bin:

tar -xzf "zincsearch_${ZINC_VERSION}_linux_x86_64.tar.gz" zincsearch
sudo install -m 0755 zincsearch /usr/local/bin/zincsearch

On an arm64 server, replace linux_x86_64 with linux_arm64 in the file name.

Step 2 - Creating a system user and data directory

Running ZincSearch as root is unnecessary. Create a dedicated system user without a login shell and a data directory it owns:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin zincsearch
sudo install -d -o zincsearch -g zincsearch -m 0750 /var/lib/zincsearch

Next, create a directory for the configuration:

sudo install -d -m 0755 /etc/zincsearch

Step 3 - Configuring ZincSearch

ZincSearch is configured entirely through environment variables. Create an environment file that systemd will load:

sudo nano /etc/zincsearch/zincsearch.env

Add the following content, replacing your_strong_password with a long random password:

ZINC_DATA_PATH=/var/lib/zincsearch
ZINC_SERVER_ADDRESS=127.0.0.1
ZINC_SERVER_PORT=4080
ZINC_FIRST_ADMIN_USER=admin
ZINC_FIRST_ADMIN_PASSWORD=your_strong_password
ZINC_TELEMETRY=false
ZINC_SENTRY=false
GIN_MODE=release

What each setting does:

  • ZINC_DATA_PATH: where indexes and metadata are stored.
  • ZINC_SERVER_ADDRESS and ZINC_SERVER_PORT: ZincSearch listens only on localhost port 4080. Nginx will be the only public entry point.
  • ZINC_FIRST_ADMIN_USER and ZINC_FIRST_ADMIN_PASSWORD: create the admin account on the very first start. They are required on first start and ignored afterwards.
  • ZINC_TELEMETRY and ZINC_SENTRY: both default to true and send usage data and crash reports to the vendor. Setting them to false keeps your server silent.
  • GIN_MODE=release: turns off the debug output of the Gin web framework.

The file contains a password, so restrict its permissions:

sudo chmod 600 /etc/zincsearch/zincsearch.env

Step 4 - Running ZincSearch as a systemd service

Create a systemd unit so ZincSearch starts at boot and restarts if it crashes:

sudo nano /etc/systemd/system/zincsearch.service
[Unit]
Description=ZincSearch full-text search engine
Documentation=https://zincsearch-docs.zinc.dev/
After=network-online.target
Wants=network-online.target

[Service]
User=zincsearch
Group=zincsearch
EnvironmentFile=/etc/zincsearch/zincsearch.env
WorkingDirectory=/var/lib/zincsearch
ExecStart=/usr/local/bin/zincsearch
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/zincsearch

[Install]
WantedBy=multi-user.target

ProtectSystem=strict mounts the whole file system read-only for the service, and ReadWritePaths gives it write access only to its data directory.

Reload systemd and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now zincsearch

Check that it is running:

sudo systemctl status zincsearch --no-pager
● zincsearch.service - ZincSearch full-text search engine
     Loaded: loaded (/etc/systemd/system/zincsearch.service; enabled; preset: enabled)
     Active: active (running) since Thu 2026-09-24 10:12:03 UTC; 5s ago

The health endpoint does not require authentication:

curl http://127.0.0.1:4080/healthz
{"status":"ok"}

The admin account now exists in the data directory. To avoid leaving the password on disk, remove the two ZINC_FIRST_ADMIN_* lines from /etc/zincsearch/zincsearch.env and restart the service with sudo systemctl restart zincsearch.

For the rest of the tutorial, store the admin credentials in a shell variable so they do not have to be typed on every command:

ZINC_AUTH='admin:your_strong_password'

Step 5 - Creating an index and loading documents

ZincSearch creates an index automatically the first time you send a document to it, guessing the type of each field. For a real application it is better to define the mapping yourself, so that exact-match fields are keyword and full-text fields are text.

Create an index called articles:

curl -u "$ZINC_AUTH" -X POST http://127.0.0.1:4080/api/index \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "articles",
    "storage_type": "disk",
    "mappings": {
      "properties": {
        "title":        {"type": "text",    "index": true, "store": true, "highlightable": true},
        "body":         {"type": "text",    "index": true, "store": true, "highlightable": true},
        "category":     {"type": "keyword", "index": true, "store": true, "sortable": true, "aggregatable": true},
        "views":        {"type": "numeric", "index": true, "store": true, "sortable": true, "aggregatable": true},
        "published_at": {"type": "date",    "index": true, "store": true, "sortable": true, "format": "2006-01-02T15:04:05Z07:00"}
      }
    }
  }'
{"message":"ok","index":"articles","storage_type":"disk"}

The supported field types are text, keyword, numeric, boolean, and date. The date format uses Go's reference-time layout, so 2006-01-02T15:04:05Z07:00 means RFC 3339.

Now load a few documents with the Elasticsearch-compatible bulk endpoint. Create a newline-delimited JSON file:

nano articles.ndjson
{"index":{"_index":"articles","_id":"1"}}
{"title":"How to configure Nginx as a reverse proxy","body":"Use proxy_pass to forward requests to a backend application.","category":"web","views":1200,"published_at":"2026-05-02T10:00:00Z"}
{"index":{"_index":"articles","_id":"2"}}
{"title":"Securing SSH with key authentication","body":"Disable password logins and use ed25519 keys.","category":"security","views":3400,"published_at":"2026-06-11T08:30:00Z"}
{"index":{"_index":"articles","_id":"3"}}
{"title":"Nginx rate limiting basics","body":"limit_req_zone protects a backend from bursts of requests.","category":"web","views":800,"published_at":"2026-07-20T14:15:00Z"}

Each document takes two lines: an action line and the document itself. The file must end with a newline.

Send it with --data-binary, which preserves the newlines:

curl -u "$ZINC_AUTH" -X POST http://127.0.0.1:4080/es/_bulk \
  -H 'Content-Type: application/x-ndjson' \
  --data-binary @articles.ndjson

The response lists one entry per document. Look for "errors":false:

{"took":0,"errors":false,"items":[{"index":{"_index":"articles","_id":"1","result":"created","status":200, ...}}, ...]}

To add or replace a single document by ID, use PUT on the _doc endpoint:

curl -u "$ZINC_AUTH" -X PUT http://127.0.0.1:4080/api/articles/_doc/4 \
  -H 'Content-Type: application/json' \
  -d '{"title":"Tuning UFW rules","body":"Allow only the ports you need.","category":"security","views":150,"published_at":"2026-08-01T09:00:00Z"}'
{"message":"ok","id":"4","_id":"4","_index":"articles","_version":1,"_seq_no":0,"_primary_term":0,"result":"created"}

Step 6 - Searching with the Elasticsearch query DSL

The /es/ prefix exposes the Elasticsearch-style search API, which accepts the familiar query DSL (match, term, range, bool, match_all) and aggregations.

Run a full-text search on the title field:

curl -u "$ZINC_AUTH" -X POST http://127.0.0.1:4080/es/articles/_search \
  -H 'Content-Type: application/json' \
  -d '{"query": {"match": {"title": "nginx"}}, "_source": ["title", "views"]}'
{"took":0,"timed_out":false,"hits":{"total":{"value":2},"max_score":0.0959,"hits":[{"_index":"articles","_id":"3","_score":0.0959,"_source":{"title":"Nginx rate limiting basics","views":800}},{"_index":"articles","_id":"1","_score":0.0729,"_source":{"title":"How to configure Nginx as a reverse proxy","views":1200}}]}}

Combine full-text matching with exact filters in a bool query. This finds documents whose body mentions "requests", in the web category, with at least 1,000 views:

curl -u "$ZINC_AUTH" -X POST http://127.0.0.1:4080/es/articles/_search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": {
      "bool": {
        "must":   [{"match": {"body": "requests"}}],
        "filter": [
          {"term":  {"category": "web"}},
          {"range": {"views": {"gte": 1000}}}
        ]
      }
    },
    "_source": ["title"]
  }'
{"took":0,"timed_out":false,"hits":{"total":{"value":1},"hits":[{"_index":"articles","_id":"1","_source":{"title":"How to configure Nginx as a reverse proxy"}}]}}

Aggregations work on fields marked aggregatable. Count documents per category:

curl -u "$ZINC_AUTH" -X POST http://127.0.0.1:4080/es/articles/_search \
  -H 'Content-Type: application/json' \
  -d '{"size": 0, "aggs": {"by_category": {"terms": {"field": "category"}}}}'
{"took":0,"timed_out":false,"hits":{"total":{"value":4},"hits":[]},"aggregations":{"by_category":{"buckets":[{"doc_count":2,"key":"security"},{"doc_count":2,"key":"web"}]}}}

Only a subset of the Elasticsearch API is implemented: indexing, bulk, search, multi-search, mappings, settings, aliases and index templates. Kibana does not work with ZincSearch; use the built-in web UI instead.

Step 7 - Creating a restricted application user

Your application should not use the admin account. ZincSearch has role-based permissions: create a role that can only search and write documents, then a user with that role.

Create the role:

curl -u "$ZINC_AUTH" -X POST http://127.0.0.1:4080/api/role \
  -H 'Content-Type: application/json' \
  -d '{"_id": "app_rw", "name": "Application read/write", "permission": ["search.SearchDSL", "document.ESBulk", "document.CreateUpdate", "document.Get", "document.Delete"]}'
{"message":"ok","id":"app_rw"}

Run curl -u "$ZINC_AUTH" http://127.0.0.1:4080/api/permissions to see the full list of permission names.

Create the user, replacing app_password with its own strong password:

curl -u "$ZINC_AUTH" -X POST http://127.0.0.1:4080/api/user \
  -H 'Content-Type: application/json' \
  -d '{"_id": "app", "name": "Application", "password": "app_password", "role": "app_rw"}'

Confirm that the new user can search but cannot delete the index:

curl -u 'app:app_password' -X DELETE http://127.0.0.1:4080/api/index/articles
{"error":"No permission:index.Delete"}

Step 8 - Publishing ZincSearch over HTTPS with Nginx

ZincSearch listens only on localhost. To reach the API and web UI from other machines, put Nginx in front of it and get a certificate from Let's Encrypt.

Install Nginx and Certbot:

sudo apt update
sudo apt install nginx certbot python3-certbot-nginx

Create a server block, replacing search.your_domain with your domain:

sudo nano /etc/nginx/sites-available/zincsearch
server {
    listen 80;
    listen [::]:80;
    server_name search.your_domain;

    client_max_body_size 50m;

    location / {
        proxy_pass http://127.0.0.1:4080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

client_max_body_size raises Nginx's 1 MB default request size so that bulk uploads are not rejected.

Enable the site, test the configuration and reload Nginx:

sudo ln -s /etc/nginx/sites-available/zincsearch /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Allow HTTP and HTTPS through UFW (keep SSH open if the firewall is not active yet):

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Request a certificate. Certbot edits the server block to add TLS and an HTTP to HTTPS redirect:

sudo certbot --nginx -d search.your_domain

Verify from your local machine:

curl -u 'app:app_password' https://search.your_domain/es/articles/_search \
  -H 'Content-Type: application/json' \
  -d '{"query": {"match": {"title": "ssh"}}, "_source": ["title"]}'

You can also open https://search.your_domain in a browser and log in to the web UI with the admin account.

Troubleshooting

The service fails on first start with "ZINC_FIRST_ADMIN_USER and ZINC_FIRST_ADMIN_PASSWORD must be set". The data directory contains no users yet and the variables are missing from the environment file. Add them back, then restart the service.

The service fails with a permission error on the data path. Check sudo journalctl -u zincsearch -n 50 and make sure /var/lib/zincsearch is owned by zincsearch:zincsearch.

Every request returns {"auth":"Invalid credentials"}. The admin password is the one set on the first start. Changing ZINC_FIRST_ADMIN_PASSWORD later has no effect. Log in to the web UI and change it there, or send a PUT /api/user request with the same _id and a new password using valid credentials.

Bulk requests report "errors":true. Inspect the items array in the response for the failing line. The usual causes are a missing trailing newline, a document spread over several lines, or using -d instead of --data-binary.

Nginx returns 413 Request Entity Too Large. Increase client_max_body_size in the server block and reload Nginx.

Conclusion

You now have ZincSearch running as a systemd service on Ubuntu 24.04, with an index, documents loaded through the bulk API, a restricted application user, and HTTPS access through Nginx. From here you can point your application at the /es/ endpoints with any HTTP client, back up /var/lib/zincsearch regularly (stop the service first for a consistent copy), and follow the ZincSearch releases to plan the upgrade to 1.0 once it is stable.