Chhoto URL on Ubuntu 24.04 on Azure User Guide
Overview
Chhoto URL is a deliberately minimal, self-hosted URL shortener written in Rust. It turns a long link into a short one, either with an auto-generated readable slug or a custom word you choose. Links can carry a note, count their hits and expire automatically. A small responsive web interface covers everyday use, and a JSON API with API-key authentication covers scripting and automation. There is no tracking and no third party in the path, so every link and every click stays in your own account.
The cloudimg image delivers Chhoto URL 7.5.0 fully installed and reverse-proxied with nginx. It is a single self-contained Rust binary with an embedded SQLite datastore, running under systemd. The application listens on the loopback interface only; nginx on port 80 is the single reachable entry point. Backed by 24/7 cloudimg support.
What is included:
- Chhoto URL 7.5.0 at
/opt/chhoto-url/chhoto-url, compiled from the upstream source tagged7.5.0and pinned to that exact commit - nginx reverse proxy on
:80in front of the Chhoto URL server on127.0.0.1:4567 - An embedded SQLite datastore at
/var/lib/chhoto-url/urls.sqlitein WAL journal mode - Secure by default: a unique password and a unique API key are generated on first boot into a root-only file, stored as Argon2id hashes, so no shared or default credential ships in the image
chhoto-url.service+nginx.serviceas systemd units, enabled and active- A fully patched Ubuntu 24.04 LTS base with
unattended-upgradesenabled - 24/7 cloudimg support
Secure by default - unique secrets on first boot
Chhoto URL signs in with a password only - there is no username field.
This matters more than usual here. Upstream Chhoto URL has no default password at all: if no password is configured, the instance is completely public and anyone can create and delete links. This image never ships in that state. On the very first boot, chhoto-url-firstboot.service generates a unique 32-character password and a unique 64-character API key for your virtual machine, writes their Argon2id hashes into the runtime environment file, and records the plaintext in /root/chhoto-url-credentials.txt (mode 0600, readable only by root).
Three independent guards make it impossible for this image to serve an open instance:
chhoto-url.servicewill not start until first boot has provisioned those secrets,- a start-up guard refuses to run the service if either secret is missing or is not an Argon2 hash, and
- only hashes are stored in the runtime configuration, so the plaintext exists in exactly one root-only file.

Prerequisites
An active Azure subscription, an SSH key pair, and a VNet + subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a comfortable starting point; this is a very light workload.
Network security group inbound rules: allow 22/tcp from your management network and 80/tcp from wherever you use the shortener. There is no need to publish port 4567. The application binds to 127.0.0.1 only, so nginx on port 80 is the sole entry point by design.
Step 1 - Deploy from the Azure Marketplace
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for Chhoto URL by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTP (80). Then Review + create and Create.
Step 2 - Deploy from the Azure CLI
az vm create \
--resource-group <your-rg> \
--name chhoto-url \
--image <marketplace-image-urn> \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values ~/.ssh/id_ed25519.pub \
--vnet-name <your-vnet> --subnet <your-subnet> \
--public-ip-sku Standard
az vm open-port --resource-group <your-rg> --name chhoto-url --port 80 --priority 1010
Step 3 - Connect to your VM
ssh azureuser@<vm-public-ip>
The message of the day shows your Chhoto URL address and where to find your secrets.
Step 4 - Confirm the services are running
Both units should report active. Note the listeners: the application is bound to 127.0.0.1:4567 and only nginx listens publicly on :80. An unauthenticated write correctly returns 401.
for s in chhoto-url.service nginx.service; do
printf '%-22s %s\n' "$s" "$(systemctl is-active "$s")"
done
printf 'first boot completed: %s\n' \
"$([ -f /var/lib/cloudimg/chhoto-url-firstboot.done ] && echo yes || echo no)"
echo
echo "listeners:"
ss -tln | grep -E ':80 |:4567 ' | awk '{print " ", $1, $4}'
echo
printf 'GET /api/version -> HTTP %s %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/api/version)" \
"$(curl -s http://127.0.0.1/api/version)"
printf 'GET / -> HTTP %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/)"
printf 'POST /api/new (no auth) -> HTTP %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
--data-binary '{"longlink":"https://example.com"}' http://127.0.0.1/api/new)"

Step 5 - Retrieve your password and API key
Both secrets were generated on first boot and stored in a root-only file, along with your site address.
sudo stat -c '%n %a %U:%G' /root/chhoto-url-credentials.txt
sudo grep -E '^CHHOTO_URL=' /root/chhoto-url-credentials.txt
echo
echo "Your password (keep it safe):"
sudo grep '^chhoto.password=' /root/chhoto-url-credentials.txt | cut -d= -f2-
echo
echo "Your API key (keep it safe):"
sudo grep '^chhoto.apikey=' /root/chhoto-url-credentials.txt | cut -d= -f2-
The runtime environment file holds only Argon2id hashes, never the plaintext:
sudo stat -c '%n %a %U:%G' /etc/chhoto-url/chhoto-url.env
sudo grep -E '^CHHOTO_(PASSWORD|API_KEY)=' /etc/chhoto-url/chhoto-url.env \
| sed -E 's/(\$argon2id\$v=[0-9]+\$m=[0-9,tp=]+\$).*/\1<salt>$<hash>/'

Confirm that your password actually authenticates and that a well-known password is rejected:
PASS="$(sudo grep '^chhoto.password=' /root/chhoto-url-credentials.txt | cut -d= -f2-)"
printf 'sign in with your per-VM password -> HTTP %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary "$PASS" \
http://127.0.0.1/api/login)"
printf 'sign in with "password" -> HTTP %s (401 = rejected)\n' \
"$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary 'password' \
http://127.0.0.1/api/login)"
Step 6 - Sign in to Chhoto URL
Browse to http://<vm-public-ip>/. Enter the password from Step 5 and select Log in. The shortener form appears above your Active links table.

Step 7 - Create your first short link
Paste a long URL into Long URL. Leave Short URL empty to get an automatically generated adjective-noun slug, or type your own word. Select Shorten! - the new short link is copied to your clipboard and appears in the Active links table with its hit counter.

More options exposes an expiry and a free-text note for each link.

Step 8 - Create short links from the API
The same operations are available over the JSON API using the API key from Step 5. The key goes in the X-API-Key header.
KEY="$(sudo grep '^chhoto.apikey=' /root/chhoto-url-credentials.txt | cut -d= -f2-)"
echo "Creating a short link:"
curl -s -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
--data-binary '{"shortlink":"azure-cli","longlink":"https://learn.microsoft.com/en-us/cli/azure/"}' \
http://127.0.0.1/api/new | jq .
echo
printf 'GET /azure-cli -> HTTP %s Location: %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/azure-cli)" \
"$(curl -s -o /dev/null -w '%{redirect_url}' http://127.0.0.1/azure-cli)"
echo
echo "All links:"
curl -s -H "X-API-Key: $KEY" http://127.0.0.1/api/all \
| jq -r '.[] | " \(.shortlink) -> \(.longlink) hits=\(.hits)"' 2>/dev/null \
|| sudo sqlite3 /var/lib/chhoto-url/urls.sqlite \
'SELECT " " || short_url || " -> " || long_url || " hits=" || hits FROM urls;'

Omit shortlink to have a slug generated for you. expiry_delay (seconds) and notes are optional:
KEY="$(sudo grep '^chhoto.apikey=' /root/chhoto-url-credentials.txt | cut -d= -f2-)"
curl -s -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
--data-binary '{"longlink":"https://www.cloudimg.co.uk/guides/","expiry_delay":86400,"notes":"expires in a day"}' \
http://127.0.0.1/api/new | jq .
Deleting a link is a DELETE against its slug:
KEY="$(sudo grep '^chhoto.apikey=' /root/chhoto-url-credentials.txt | cut -d= -f2-)"
printf 'DELETE /api/del/azure-cli -> HTTP %s\n' \
"$(curl -s -o /dev/null -w '%{http_code}' -X DELETE -H "X-API-Key: $KEY" \
http://127.0.0.1/api/del/azure-cli)"
Your public address
Short links are returned using the address detected on first boot, so they are usable immediately. That value lives in CHHOTO_SITE_URL:
sudo grep '^CHHOTO_SITE_URL=' /etc/chhoto-url/chhoto-url.env
If you put a DNS name in front of the VM, set that name instead so generated links use it:
sudo sed -i 's#^CHHOTO_SITE_URL=.*#CHHOTO_SITE_URL=https://links.your-domain.example#' \
/etc/chhoto-url/chhoto-url.env
sudo systemctl restart chhoto-url
The datastore
The whole application state is one SQLite file in WAL journal mode.
sudo ls -l /var/lib/chhoto-url/urls.sqlite
printf 'journal mode: %s\n' \
"$(sudo sqlite3 /var/lib/chhoto-url/urls.sqlite 'PRAGMA journal_mode;')"
printf 'links stored: %s\n' \
"$(sudo sqlite3 /var/lib/chhoto-url/urls.sqlite 'SELECT COUNT(*) FROM urls;')"
Backing up and restoring
Take a consistent copy with SQLite's online backup, which is safe while the service is running:
sudo install -d -m 0750 /var/backups/chhoto-url
sudo sqlite3 /var/lib/chhoto-url/urls.sqlite \
".backup '/var/backups/chhoto-url/urls-backup.sqlite'"
sudo sqlite3 /var/backups/chhoto-url/urls-backup.sqlite 'PRAGMA integrity_check;'
sudo ls -l /var/backups/chhoto-url/
To restore, stop the service, copy the backup over /var/lib/chhoto-url/urls.sqlite, restore ownership to chhoto:chhoto, and start the service again.
Changing your password or API key
The runtime environment file stores Argon2id hashes, so hash the new value before writing it:
printf '%s' 'your-new-secret' | argon2 "$(openssl rand -hex 16)" -id -t 3 -m 15 -p 1 -e
Put the resulting $argon2id$... string into CHHOTO_PASSWORD (or CHHOTO_API_KEY) in /etc/chhoto-url/chhoto-url.env, then restart:
sudo systemctl restart chhoto-url
Update /root/chhoto-url-credentials.txt too so the record stays accurate. Secrets must be 127 characters or shorter - that is the reference argon2 tool's input limit.
Security updates
The base OS is fully patched at build time and unattended-upgrades stays enabled, so security updates continue to apply on your VM.
echo "held packages (should be none):"
H="$(apt-mark showhold)"; [ -z "$H" ] && echo " (none)" || echo "$H"
echo
printf 'packages still upgradable: %s\n' \
"$(apt-get -s -o APT::Get::Always-Include-Phased-Updates=true dist-upgrade | grep -c '^Inst ' || true)"
echo
printf 'unattended-upgrades: %s\n' "$(systemctl is-enabled unattended-upgrades.service)"
grep -h Unattended /etc/apt/apt.conf.d/20auto-upgrades
echo
echo "swap (must be empty on an Azure image):"
S="$(swapon --show)"; [ -z "$S" ] && echo " (empty)" || echo "$S"

Enabling HTTPS with Let's Encrypt
Point a DNS A record at your VM's public IP, open 443/tcp in the network security group, then issue a certificate. nginx is already the front end, so certbot can configure it in place:
sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d <your-domain> --agree-tos -m <your-email> --redirect
Certbot installs a renewal timer automatically. Afterwards set CHHOTO_SITE_URL to the https:// address so generated links use it.
Tuning
Configuration is read from /etc/chhoto-url/chhoto-url.env at start-up. Useful settings, all optional:
CHHOTO_SLUG_STYLE-Pairfor adjective-noun slugs (default here) orUIDCHHOTO_SLUG_LENGTH- length of generated slugs, minimum4CHHOTO_ALLOW_CAPITAL_LETTERS- set toTrueto permit capitals in slugsCHHOTO_REDIRECT_METHOD-TEMPORARYto send temporary rather than permanent redirectsCHHOTO_FRONTEND_PAGE_SIZE- rows per page in the links table
Apply changes with sudo systemctl restart chhoto-url.
Troubleshooting
echo "--- recent chhoto-url log ---"
journalctl -u chhoto-url.service --no-pager -n 15
echo
echo "--- nginx configuration test ---"
nginx -t 2>&1
echo
echo "--- effective configuration (secrets not shown) ---"
sudo grep -vE '^CHHOTO_PASSWORD=|^CHHOTO_API_KEY=|^\s*#|^\s*$' /etc/chhoto-url/chhoto-url.env
If the service is not running, the usual cause is that the per-VM secrets are missing, which the start-up guard deliberately treats as fatal rather than serving an open instance. Confirm CHHOTO_PASSWORD and CHHOTO_API_KEY are set to Argon2 hashes in /etc/chhoto-url/chhoto-url.env, then sudo systemctl restart chhoto-url. If the web interface is unreachable from outside the VM but curl http://127.0.0.1/ succeeds locally, check the network security group allows 80/tcp.
Support
This image is published and supported by cloudimg. For assistance, contact support@cloudimg.co.uk. Chhoto URL is open source software distributed under the MIT licence; see the upstream project for product documentation.