Fl
Application Infrastructure Azure

Formance Ledger on Ubuntu 24.04 on Azure User Guide

| Product: Formance Ledger on Ubuntu 24.04 on Azure

Overview

This guide covers the deployment and use of Formance Ledger on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. Formance Ledger is an open-source programmable double-entry financial core ledger: an HTTP API that records money movements between accounts as immutable, atomic, multi-posting transactions, enforces double-entry accounting invariants, and is programmable in Numscript, a small domain-specific language for expressing balanced postings.

This image ships the standalone Formance Ledger micro-service only. The ledger runs as a single Go binary against a local PostgreSQL 16 database on the same single VM. It is not the wider Formance Platform: there is no separate gateway, no bundled web console, no message broker and no object storage. The ledger is the state-keeping engine, and you drive it over its HTTP API.

Formance Ledger has no web interface. It is an API server: HTTP requests in, JSON out. Everything in this guide is done with curl and standard HTTP clients, which is what a financial API is meant for. Point your own application at it.

Security by design, deny by default. Because a ledger holds financial records, this appliance refuses first:

  • The ledger process and PostgreSQL are both bound to the loopback interface only and are never reachable off the VM.
  • The only routable port is 8080, served by an nginx reverse proxy that enforces HTTP Basic authentication against a credential unique to your VM. Every request without that credential is refused with HTTP 401 — including writes.
  • Nothing is baked into the image. On first boot each VM generates a unique password for the postgres superuser, a unique password for the ledger database role, and a unique API password, and writes them to the root-only file /root/formance-ledger-credentials.txt. The shipped image contains an empty credential store and an empty ledger database.

What is included:

  • Formance Ledger v2.4.12 from the official upstream release, verified against a pinned SHA-256 checksum at build time, running under systemd as formance-ledger.service (with the async worker embedded in-process)

  • PostgreSQL 16 from the official PostgreSQL PGDG repository, bound to loopback, running as postgresql.service

  • nginx as a Basic-auth reverse proxy on port 8080, the only routable interface

  • Per-VM passwords generated on first boot and written to a root-only credentials file, with no default login

  • formance-ledger-selfcheck, a helper that verifies the whole appliance — loopback binding, anonymous refusal, correct double-entry, and rejection of invalid transactions — end to end

  • Unattended security upgrades left enabled so the appliance keeps receiving patches

Formance is a trademark of Formance Solutions. This image is produced by cloudimg and is not affiliated with, endorsed by, or sponsored by Formance. The name is used only to identify the open-source software that runs in the image.

Prerequisites

  • Active Azure subscription, an SSH public key, and a VNet and subnet in the target region

  • Subscription to this listing on Azure Marketplace

  • A Network Security Group allowing TCP 22 for administration and TCP 8080 for the Ledger API. In production, restrict 8080 to your application subnet — the ledger holds financial data.

Recommended virtual machine size: Standard_B2s with 2 vCPU and 4 GB RAM for development and light workloads. For higher transaction rates, choose a larger size such as Standard_D2s_v5 or above.

Deploy the virtual machine

Deploy from the Azure Portal by selecting the image from Azure Marketplace, choosing your VM size, and supplying your SSH public key for the azureuser account. Or deploy from the Azure CLI. These commands run on your own workstation, not on the VM:

az vm create \
  --resource-group my-resource-group \
  --name my-formance-ledger \
  --image <marketplace-image-urn> \
  --size Standard_B2s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

Then open the API port to your client network (tighten the source range in production):

az vm open-port --resource-group my-resource-group --name my-formance-ledger --port 8080

SSH in as azureuser:

ssh azureuser@<vm-ip>

Confirm the services are healthy

On the first boot the appliance rotates its credentials, runs the ledger schema migrations against the empty database, and starts the API. Confirm all three services are active:

systemctl is-active postgresql formance-ledger nginx

All three report active. The ledger listens on 127.0.0.1:3068 and PostgreSQL on 127.0.0.1:5432 — both loopback only — while nginx exposes the single routable port 8080.

PostgreSQL, the Formance Ledger and nginx all reporting active under systemd, with the ledger on 3068 and PostgreSQL on 5432 bound to loopback and only nginx on 8080 routable

Retrieve your per-VM credentials

Every secret is generated on first boot and written to a root-only file. Read it with:

sudo cat /root/formance-ledger-credentials.txt

The api.user (default ledger) and api.password are the HTTP Basic credentials for the API on port 8080. Keep this file safe; the secrets are unique to your VM and are shown in plain text only here.

The API is deny by default

Every request to the routable API needs the per-VM credential. An anonymous request — read or write — is refused with HTTP 401 before it ever reaches the ledger:

# anonymous write is refused
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
  -d '{"metadata":{}}' http://localhost:8080/v2/main/transactions
# anonymous read is refused too
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/_info

Both print 401. With the per-VM credential the same server info request succeeds with 200:

curl -s -o /dev/null -w '%{http_code}\n' -u ledger:<API_PASSWORD> http://localhost:8080/_info

An anonymous write and an anonymous read to the routable API both refused with HTTP 401, while the authenticated request with the per-VM credential returns HTTP 200, plus the root-only credentials file

Create a ledger and post your first transaction

A ledger is an isolated set of accounts and transactions. Create one named main:

curl -s -u ledger:<API_PASSWORD> -X POST http://localhost:8080/v2/main \
  -H 'Content-Type: application/json' -d '{}'

Now post a transaction. Every transaction is a set of postings that move an amount of an asset from a source account to a destination account. The special world account is the external source of funds and is the only account allowed to hold a negative balance. This posting moves USD/2 (US dollars, 2 decimal places) 100 from world into users:alice:

curl -s -u ledger:<API_PASSWORD> -X POST http://localhost:8080/v2/main/transactions \
  -H 'Content-Type: application/json' \
  -d '{"metadata":{},"postings":[{"amount":100,"asset":"USD/2","source":"world","destination":"users:alice"}]}'

You can also express the same movement in Numscript, the ledger's built-in DSL for multi-account postings:

curl -s -u ledger:<API_PASSWORD> -X POST http://localhost:8080/v2/main/transactions \
  -H 'Content-Type: application/json' \
  -d '{"metadata":{},"script":{"plain":"send [USD/2 100] (\n  source = @world\n  destination = @users:alice\n)"}}'

Read the resulting balances. Double-entry means the destination gained exactly what the source lost, and the sum across the transaction is zero:

curl -s -u ledger:<API_PASSWORD> 'http://localhost:8080/v2/main/accounts/users:alice?expand=volumes'
curl -s -u ledger:<API_PASSWORD> 'http://localhost:8080/v2/main/accounts/world?expand=volumes'

users:alice shows a balance of 100 and world shows -100 for USD/2.

A Numscript transaction record showing the posting from world to users:alice, and the resulting double-entry balances of destination plus one hundred and world minus one hundred

Invalid transactions are rejected

Enforcing accounting invariants is the whole point of a ledger. A normal account cannot go implicitly negative: only world may. This transaction tries to move funds out of an account that has never received any, so the ledger rejects it with INSUFFICIENT_FUND and changes no balances:

curl -s -u ledger:<API_PASSWORD> -X POST http://localhost:8080/v2/main/transactions \
  -H 'Content-Type: application/json' \
  -d '{"metadata":{},"script":{"plain":"send [USD/2 50] (\n  source = @users:emptywallet\n  destination = @users:bob\n)"}}'

The response carries "errorCode":"INSUFFICIENT_FUND". You can verify the whole appliance — loopback binding, anonymous refusal, a correct double-entry posting, and this rejection — in one command:

sudo formance-ledger-selfcheck

The appliance self-check reporting a healthy, deny-by-default ledger with correct double-entry, and a live insufficient-funds transaction rejected with the INSUFFICIENT_FUND error code

Where to go next

  • Model your own money flows in Numscript — see the Numscript documentation — to express fees, splits, and multi-destination sends atomically.
  • Add metadata to accounts and transactions to carry your domain identifiers.
  • Query balances, volumes and the full immutable transaction log through the /v2/{ledger} API.
  • The API is documented in the OpenAPI specification shipped by the upstream project.

Security notes

  • The ledger and PostgreSQL are loopback only. Nothing but nginx is reachable from the network, so the only way to the ledger is through Basic authentication on port 8080.
  • No default credentials. The postgres superuser password, the ledger database password and the API password are all generated uniquely on first boot. The shipped image has an empty credential store and an empty database.
  • Rotate the API password by regenerating the nginx credential: printf '%s' 'your-new-password' | sudo htpasswd -i /etc/nginx/formance-ledger.htpasswd ledger && sudo systemctl reload nginx.
  • In production, restrict TCP 8080 to your application subnet with the Azure NSG, and consider terminating TLS at nginx or a load balancer in front of it.
  • Back up PostgreSQL (sudo -u postgres pg_dump ledger) as you would any system of record; the ledger's data lives entirely in the ledger database.
  • Unattended security upgrades are left enabled so the OS keeps receiving patches.