Can I self-host QuickBooks Online?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Akaunting. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $38 a month you stop paying QuickBooks Online — $456 a year on the Simple Start plan.

Why people pay for QuickBooks Online

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

QuickBooks sells the boring middle of running a business, and it sells it to two people at once. To you: bank and card feeds that pull yesterday's transactions in overnight, payroll that files the tax deposits, sales tax that knows your state, and receipts you photograph rather than file. To your accountant: a file they already know, with an accountant login, a trial balance and a year-end close they have done a thousand times. That second audience is the part self-hosting cannot buy back, and it is the reason people who dislike the subscription keep paying it.

QuickBooks Online plans and list prices
PlanList priceWhat it buys
Simple Startthe plan this page prices against$38/moOne user plus accountant access. Income and expense tracking, invoicing and payments, bank feeds, sales tax and general reports. Intuit's August 2026 price change left this tier unchanged.
Essentials$85/moThree users. Adds bill management, multi-currency and time tracking. Raised from $75 on 1 August 2026.
Plus$140/moFive users. Adds inventory, project profitability and budgets. Raised from $115 on 1 August 2026.
Advanced$340/moTwenty-five users, with workflow automation, custom roles and a dedicated support line. Raised from $275 on 1 August 2026.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by Akaunting

One project, named before the prompt, so you know what you are about to install.

Invoices, bills, expenses and a client portal on your own domain, with the double-entry ledger sold as a paid app.

The closest thing here to the shape of QuickBooks rather than one half of it: customers and vendors, invoices and bills, transactions and transfers, a client portal, a profit and loss and a tax summary, in two containers on your own domain. Two things have to be said in the same breath. Its licence is source-available, not open source, and the free production grant stops at two users, one company and one thousand invoices, which happens to be about the shape of a Simple Start subscription. And the double-entry half of the name, the chart of accounts, general ledger and balance sheet, is a $72-a-year app rather than part of the free core, with bank feeds another $132 a year. Pick it when you keep the books yourself and hand an accountant a P&L; do not pick it expecting a balance sheet on day one.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Akauntingour pickONE EVENING— setup effort 2 of 4

    Invoices, bills, expenses and a client portal on your own domain, with the double-entry ledger sold as a paid app.

    The closest thing here to the shape of QuickBooks rather than one half of it: customers and vendors, invoices and bills, transactions and transfers, a client portal, a profit and loss and a tax summary, in two containers on your own domain. Two things have to be said in the same breath. Its licence is source-available, not open source, and the free production grant stops at two users, one company and one thousand invoices, which happens to be about the shape of a Simple Start subscription. And the double-entry half of the name, the chart of accounts, general ledger and balance sheet, is a $72-a-year app rather than part of the free core, with bank feeds another $132 a year. Pick it when you keep the books yourself and hand an accountant a P&L; do not pick it expecting a balance sheet on day one.

  2. Invoice NinjaONE WEEKEND— setup effort 3 of 4

    Invoices, quotes, expenses and a client portal on your own domain, with no cap on how many clients you bill.

    Second here, and first if the half of QuickBooks you actually use is getting paid. Invoice Ninja does the billing loop deeper than Akaunting does, with recurring invoices, quotes, a client portal and payment-gateway integrations you connect yourself, and it puts no cap on users, clients or invoices. What it does not do is bookkeeping: there are expenses and reports, but no vendor bills ledger, no chart of accounts, and nothing an accountant will treat as a set of books. It is also heavier to run, four containers, and source-available under the Elastic License.

The swap

You're paying

QuickBooks Online

$38/mo · $456/yr

is replaced by

You'd run

Akaunting

ONE EVENING · ~90 min to running · 2048 MB RAM

QuickBooks Online Simple Start · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~90 min1–3 hours, through the first backup

The prompt

Two paths to the same Akaunting: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

336 lines · 14,704 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Akaunting 3.1.21 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until
they answer. `<DOMAIN>` becomes `APP_URL`, the `<base href>` on every page and the address in
every client-portal link a customer gets; its A record must already point here.
`<ADMIN_EMAIL>` is the sign-in name of the one account this creates.

Tell the user this first: Akaunting is source-available rather than open source, and its
licence grants free production use for up to two users, one company and one thousand invoices.
Past any of those, upstream sells an on-premise plan.

Akaunting needs 2048 MB of RAM available and 10 GB free on /srv. Both images have arm64.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a name that does not resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/akaunting /srv/akaunting/backups
sudo install -d -m 700 /srv/akaunting/mariadb
ls -la /srv/akaunting
```

Assert: `backups` owned by the login user, `mariadb` at mode `700` owned by root. Leave that
one alone: MariaDB chowns its data directory on first start and refuses one somebody claimed
first. The application gets no directory here; step 4 keeps it in a named volume.

## 3. Secrets

Three secrets, all generated here: the MariaDB password for the `akaunting` database user, the
MariaDB root password, and the password the installer puts on the first account. Print none of
them, keep them out of your summary, and keep them out of every log line.

```bash
umask 077
cat > /srv/akaunting/.env <<EOF
APP_URL=https://<DOMAIN>
LOCALE=en-US
COMPANY_NAME=My Company
COMPANY_EMAIL=<ADMIN_EMAIL>
ADMIN_EMAIL=<ADMIN_EMAIL>
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
AKAUNTING_SETUP=true
EOF
chmod 600 /srv/akaunting/.env
umask 022
ls -l /srv/akaunting/.env
```

Assert: mode `-rw-------` and the login user's name twice. Compose reads it for the `${...}`
substitutions in compose.yml whenever it runs from /srv/akaunting, so it is never mounted into
a container. `AKAUNTING_SETUP` runs the installer once, and step 7 takes it away.

## 4. compose.yml

```bash
cat > /srv/akaunting/compose.yml <<'EOF'
# Akaunting · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image README ... https://github.com/akaunting/docker/blob/master/README.md
#   entrypoint ..... https://github.com/akaunting/docker/blob/master/files/akaunting.sh
#   variables ...... https://github.com/akaunting/docker/blob/master/env/run.env.example
#
# Akaunting's Apache image and the MariaDB holding the books. /var/www/html is
# a named volume because the image ships the application there and chowns it;
# MariaDB chowns its own directory, so that one is the bind mount. 3.1.21 is the
# newest tag akaunting/docker has published, and 3.2.1 has no image behind it.
# Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: akaunting-db
    restart: unless-stopped
    # Upstream's install page asks for utf8mb4_general_ci.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: akaunting
      MARIADB_USER: akaunting
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - /srv/akaunting/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  akaunting:
    image: akaunting/akaunting:3.1.21@sha256:50940112be48a229a2f567dc50ace9886fe5b14e1fe33f0232e704d0fb96f29f
    container_name: akaunting
    restart: unless-stopped
    environment:
      # The <base href> on every page: the scheme and host Caddy answers on.
      APP_URL: ${APP_URL}
      LOCALE: ${LOCALE}
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: akaunting
      DB_USERNAME: akaunting
      DB_PASSWORD: ${DB_PASSWORD}
      DB_PREFIX: ""
      COMPANY_NAME: ${COMPANY_NAME}
      COMPANY_EMAIL: ${COMPANY_EMAIL}
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      # The installer runs while AKAUNTING_SETUP is set; step 7 deletes it.
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
      AKAUNTING_SETUP: ${AKAUNTING_SETUP:-}
    volumes:
      - akaunting-html:/var/www/html
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8151.
      - "127.0.0.1:8151:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  akaunting-html:
EOF
cd /srv/akaunting && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No default credential survives it: upstream's example
environment carries a published database password and `me@company.com` on the first account,
and step 3 replaced both. PHP's own defaults ride along, so an attachment over 2 MB is refused.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-akaunting
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Akaunting · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/akaunting/docker/blob/master/env/run.env.example and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, the <base href> on every page, so the two stay equal.

<DOMAIN> {
	# The interface is HTML and JSON; PDFs are already compressed.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
	# No Content-Security-Policy: invoice templates are editable here, and
	# one written blind breaks a printed invoice rather than an attack.

	# 8151 is the loopback port compose publishes here, not a container
	# port, and not open in the firewall.
	reverse_proxy 127.0.0.1:8151
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-akaunting, reload,
and report the objection. Caddy gets the certificate on the first request, then renews it.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp
is HTTP/3. 8151 is bound to 127.0.0.1 and 3306 is never published, so neither has a host port
to firewall. Assert: `Status: active`, rules for 80, 443/tcp, 443/udp, nothing else.

## 7. Start and verify

MariaDB initialises, then the entrypoint runs `php artisan install`: it writes the
application's own .env inside the volume with a fresh `APP_KEY`, builds the schema, and creates
the company and the account. Apache starts when that finishes, a minute or two later.

```bash
cd /srv/akaunting
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/auth/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/auth/login | grep -o 'Login to start your session'
docker compose logs akaunting | grep -c 'Creating admin'
```

Assert all three, printing what you got: the loop ends on `200`; then
`Login to start your session`; then `1`, the installer's own line for the account it made. On
any miss, stop, run `docker compose logs --tail 60 akaunting` and
`docker compose logs --tail 20 db`, and name the step: `Unable to find database!` is step 3's
password never reaching the database, `Missing options are` is an empty value in .env, a
lasting `502` is step 5. A running container is not success.

The first screen at https://<DOMAIN>/ redirects to https://<DOMAIN>/auth/login, which shows the
Akaunting logo over the line `Login to start your session`, an `Email` box, a `Password` box
and a `Login` button.

STOP: tell the user to read the password with `grep ADMIN_PASSWORD /srv/akaunting/.env`, put it
in their password manager, sign in at https://<DOMAIN>/auth/login with `<ADMIN_EMAIL>`, rename
the company under Settings, confirm the dashboard loads, and wait.
Do not continue until they confirm. The next block deletes this server's copy of that password.

Close the bootstrap out:

```bash
cd /srv/akaunting
sed -i -e '/^ADMIN_PASSWORD/d' -e '/^AKAUNTING_SETUP/d' /srv/akaunting/.env
docker compose up -d --force-recreate akaunting
sleep 45
docker compose logs akaunting | grep -c 'Creating admin' || true
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/auth/login
```

Assert both: the count that read `1` now prints `0`, and the status prints `200`. That `0` is
the security assert here, because the replaced container starts Apache without running the
installer again: no second company, no second account, no bootstrap password on disk. There is
no reset mail here, so that password manager entry is the recovery plan.

## 8. First backup and restore

Three artifacts: the dump holds customers, vendors, invoices, bills and transactions; the
application archive holds the volume's .env, whose `APP_KEY` decrypts what Laravel encrypted,
plus attachments; the config archive rebuilds the service around both.

```bash
cd /srv/akaunting
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction --no-tablespaces -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/akaunting/backups/akaunting-db-$(date +%F).sql.gz
docker compose exec -T akaunting tar -C /var/www/html -czf - .env storage modules > /srv/akaunting/backups/akaunting-app-$(date +%F).tar.gz
sudo tar -czf /srv/akaunting/backups/akaunting-config-$(date +%F).tar.gz -C /srv/akaunting compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/akaunting/backups/
```

Assert: all three exist, all three are non-empty, and print all three sizes. Nothing goes
offline: `--single-transaction` snapshots a running InnoDB database, and `--no-tablespaces` is
there because the `akaunting` user is not a superuser. The password is read inside the database
container, so it never reaches the host process list.

A backup on the same disk is not a backup. Run this from the user's machine, not the server:

```bash
mkdir -p ~/backups/akaunting
scp vps:/srv/akaunting/backups/* ~/backups/akaunting/
```

To restore: `docker compose down -v`, the one place `-v` belongs, because it drops the
application volume on purpose. `sudo rm -rf /srv/akaunting/mariadb` and recreate it as in step
2. Untar the config archive into /srv/akaunting so .env is back first, since MariaDB reads its
passwords from it as it initialises. `docker compose up -d db`, wait 30 seconds for healthy,
pipe `gunzip -c` on the dump into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`.
Then `docker compose up -d akaunting`, which refills the volume from the image,
`docker compose exec -T akaunting tar -C /var/www/html -xzf - < backups/akaunting-app-<date>.tar.gz`,
and `docker compose restart akaunting` to put the ownership back. What is at stake at 2am: this
database is what they file taxes from.

## 9. Updating later

Two things move separately. A newer image tag moves PHP, Apache and the image's own copy of
the application, but the copy that runs lives in the `akaunting-html` volume, which Docker
filled once and will not fill again, so these three move the runtime and nothing else:

```bash
cd /srv/akaunting
docker compose pull
docker compose up -d
docker compose logs --tail 30 akaunting
```

Image tags are at https://hub.docker.com/r/akaunting/akaunting/tags and releases at
https://github.com/akaunting/akaunting/releases. Take all three backups first, then edit the
image line in compose.yml to the new tag and digest. Akaunting itself moves with the updater
upstream documents: `docker compose exec -T akaunting php artisan update:all`.

## 10. What will probably go wrong

The first two minutes look like a failed install. `docker compose ps` says the application
container is `Up`, curl returns nothing at all, and the log sits on
`Connecting to database akaunting@db:3306` while the entrypoint retries every five seconds. I
went hunting for the bug and there was not one: the entrypoint runs the whole installer before
Apache starts, so there is no half-built page to look at while it works. Give step 7's loop its
full 40 rounds. When it fails for real it fails loudly, with `Unable to find database!` after
30 seconds of retries, and that points at step 3.

## 11. Out of scope

- Do not configure SMTP. Akaunting runs without it, and the cost is that Email Invoice does
  nothing, so the user sends the PDF or the portal link themselves.
- Do not add a cron container or a scheduler service. Nothing here runs Laravel's scheduler, so
  recurring invoices and reminder mail never fire.
- Do not enter an Akaunting API key or install anything from the app store. Those apps are
  purchases tied to an akaunting.com account, and the double-entry ledger is one of them.
- Do not create a second company or extra user accounts. The licence grants production use for
  one company and two users.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Akaunting 3.1.21 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box and `<ADMIN_EMAIL>` with the address you want to sign in as.

Read this before step 1. Akaunting is source-available rather than open source, and its licence
grants free production use for up to two users, one company and one thousand invoices. Past any
of those you buy an on-premise plan from upstream. The double-entry ledger, the bank feeds and
the inventory module are separate paid apps; what this installs is invoices, bills, expenses, a
client portal, a profit and loss and a tax summary.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a name that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 2048 MB of
available memory, stop and resize the box: PHP under Apache and a MariaDB on 1 GB is an install
that dies during the first month-end report rather than during the install.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/akaunting /srv/akaunting/backups
sudo install -d -m 700 /srv/akaunting/mariadb
ls -la /srv/akaunting
```

You should see: `backups` owned by you, and `mariadb` at mode `drwx------` owned by root.

If you do not: leave `mariadb` owned by root on purpose. The MariaDB image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. There is no directory for the application here, and that is also on
purpose: step 4 keeps it in a named volume, because the image ships Akaunting inside
/var/www/html and a bind mount over that path would hide it.

## 3. Secrets

Three secrets: the MariaDB password for the `akaunting` database user, the MariaDB root
password, and the password the installer puts on your first account. All three are generated
here, on the server, into a file only you can read. Replace `<DOMAIN>` and both copies of
`<ADMIN_EMAIL>` before you paste.

```bash
umask 077
cat > /srv/akaunting/.env <<EOF
APP_URL=https://<DOMAIN>
LOCALE=en-US
COMPANY_NAME=My Company
COMPANY_EMAIL=<ADMIN_EMAIL>
ADMIN_EMAIL=<ADMIN_EMAIL>
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
AKAUNTING_SETUP=true
EOF
chmod 600 /srv/akaunting/.env
umask 022
ls -l /srv/akaunting/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

Do not paste that file, either password, or any command output containing them into this chat
window. The agent path never sees those values; this one hands them to a third party unless you
keep them off the screen. Read your own password once, when step 7 tells you to, and put it
straight into your password manager.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/akaunting/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
all three values, which is fine before the database exists and a problem afterwards: MariaDB
keeps the password it was created with, so a changed `DB_PASSWORD` on an existing data
directory shows up as `Unable to find database!` in the application log rather than as anything
about passwords.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/akaunting/compose.yml <<'EOF'
# Akaunting · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image README ... https://github.com/akaunting/docker/blob/master/README.md
#   entrypoint ..... https://github.com/akaunting/docker/blob/master/files/akaunting.sh
#   variables ...... https://github.com/akaunting/docker/blob/master/env/run.env.example
#
# Akaunting's Apache image and the MariaDB holding the books. /var/www/html is
# a named volume because the image ships the application there and chowns it;
# MariaDB chowns its own directory, so that one is the bind mount. 3.1.21 is the
# newest tag akaunting/docker has published, and 3.2.1 has no image behind it.
# Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: akaunting-db
    restart: unless-stopped
    # Upstream's install page asks for utf8mb4_general_ci.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: akaunting
      MARIADB_USER: akaunting
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - /srv/akaunting/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  akaunting:
    image: akaunting/akaunting:3.1.21@sha256:50940112be48a229a2f567dc50ace9886fe5b14e1fe33f0232e704d0fb96f29f
    container_name: akaunting
    restart: unless-stopped
    environment:
      # The <base href> on every page: the scheme and host Caddy answers on.
      APP_URL: ${APP_URL}
      LOCALE: ${LOCALE}
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: akaunting
      DB_USERNAME: akaunting
      DB_PASSWORD: ${DB_PASSWORD}
      DB_PREFIX: ""
      COMPANY_NAME: ${COMPANY_NAME}
      COMPANY_EMAIL: ${COMPANY_EMAIL}
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      # The installer runs while AKAUNTING_SETUP is set; step 7 deletes it.
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
      AKAUNTING_SETUP: ${AKAUNTING_SETUP:-}
    volumes:
      - akaunting-html:/var/www/html
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8151.
      - "127.0.0.1:8151:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  akaunting-html:
EOF
cd /srv/akaunting && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/akaunting/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/akaunting/compose.yml` and paste again in one go. Two things in that file are
worth knowing. `AKAUNTING_SETUP` is what makes the container run the installer, once, and step
7 removes it. PHP's own defaults ride along untouched, so an attachment larger than 2 MB is
refused; that ceiling belongs to the image rather than to this file.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-akaunting
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Akaunting · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/akaunting/docker/blob/master/env/run.env.example and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, the <base href> on every page, so the two stay equal.

<DOMAIN> {
	# The interface is HTML and JSON; PDFs are already compressed.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
	# No Content-Security-Policy: invoice templates are editable here, and
	# one written blind breaks a printed invoice rather than an attack.

	# 8151 is the loopback port compose publishes here, not a container
	# port, and not open in the firewall.
	reverse_proxy 127.0.0.1:8151
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-akaunting /etc/caddy/Caddyfile`,
reload, and paste again. The hostname in this block and `APP_URL` in step 3 have to be the same
string: Akaunting prints `APP_URL` as the `<base href>` of every page, so a mismatch shows up as
a login form with no styling rather than as an error.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8151` or `3306`.

If you do not: delete anything for `8151` or `3306` with `sudo ufw delete allow 8151`. 8151 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and to answer
the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

MariaDB initialises, then the entrypoint runs `php artisan install`: it writes the
application's own .env inside the volume with a fresh `APP_KEY`, builds the schema, and creates
the company and the account. Apache starts only when that finishes, a minute or two later, so
the first two minutes look like nothing is happening.

```bash
cd /srv/akaunting
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/auth/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/auth/login | grep -o 'Login to start your session'
docker compose logs akaunting | grep -c 'Creating admin'
```

You should see, in order: the loop reaching `200`, the line `Login to start your session`, and
then `1`.

If you do not: run `docker compose logs --tail 60 akaunting`. `Unable to find database!` after
about 30 seconds of retries means step 3's password never reached the database container, which
usually means .env was rewritten after MariaDB had already initialised its data directory.
`Missing options are` means one of the identity values in .env is empty, most often because you
pasted the heredoc with `<ADMIN_EMAIL>` still literal. A `502` that never clears is step 5.
A green `docker compose ps` is not success on its own; the three checks above are.

The first screen at https://<DOMAIN>/ redirects to https://<DOMAIN>/auth/login, which shows the
Akaunting logo over the line `Login to start your session`, an `Email` box, a `Password` box
and a `Login` button.

Now read your password, once, and sign in:

```bash
grep ADMIN_PASSWORD /srv/akaunting/.env
```

You should see: one line with a long random value. Put it in your password manager now, sign in
at https://<DOMAIN>/auth/login with `<ADMIN_EMAIL>`, and rename the company under Settings so
your invoices carry your own name rather than `My Company`. Do not paste that line into this
chat.

If you do not: an empty result means step 3 never ran or was overwritten. There is no
password-reset email on this install, because nothing here sends mail, so if you lose that
value the recovery is a console command on the server rather than a link in your inbox.

Once you are on the dashboard, close the bootstrap out:

```bash
cd /srv/akaunting
sed -i -e '/^ADMIN_PASSWORD/d' -e '/^AKAUNTING_SETUP/d' /srv/akaunting/.env
docker compose up -d --force-recreate akaunting
sleep 45
docker compose logs akaunting | grep -c 'Creating admin' || true
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/auth/login
```

You should see: `0`, then `200`.

If you do not: a count above `0` means the two lines are still in .env, so check
`grep -c AKAUNTING /srv/akaunting/.env` and paste the block again. That `0` is the point of
this step: the replaced container brings Apache up without running the installer a second time,
so there is no second company, no second account, and no bootstrap password sitting on the
disk.

## 8. First backup and restore

Three artifacts. The dump holds the customers, vendors, invoices, bills and transactions. The
application archive holds the .env from inside the volume, whose `APP_KEY` is what Laravel
encrypts sessions and stored credentials with, plus your uploaded attachments. The config
archive rebuilds the service around both.

```bash
cd /srv/akaunting
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction --no-tablespaces -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/akaunting/backups/akaunting-db-$(date +%F).sql.gz
docker compose exec -T akaunting tar -C /var/www/html -czf - .env storage modules > /srv/akaunting/backups/akaunting-app-$(date +%F).tar.gz
sudo tar -czf /srv/akaunting/backups/akaunting-config-$(date +%F).tar.gz -C /srv/akaunting compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/akaunting/backups/
```

You should see: three files, the dump a few tens of kilobytes on a fresh install and the
application archive a few megabytes. Nothing goes offline: `--single-transaction` snapshots a
running InnoDB database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump`
failed and the shell created the file anyway. Run the dump line without `| gzip` to read the
error. `--no-tablespaces` is in there because the `akaunting` user is not a superuser and the
dump fails without it.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/akaunting
scp vps:/srv/akaunting/backups/* ~/backups/akaunting/
```

You should see: three files copied, and all three listed by `ls -lh ~/backups/akaunting/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty company:

```bash
cd /srv/akaunting
docker compose down -v
sudo rm -rf /srv/akaunting/mariadb
sudo install -d -m 700 /srv/akaunting/mariadb
tar -xzf backups/akaunting-config-$(date +%F).tar.gz -C /srv/akaunting compose.yml .env
docker compose up -d db
sleep 30
gunzip -c backups/akaunting-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose up -d akaunting
sleep 90
docker compose exec -T akaunting tar -C /var/www/html -xzf - < backups/akaunting-app-$(date +%F).tar.gz
docker compose restart akaunting
sleep 30
curl -sS https://<DOMAIN>/auth/login | grep -o 'Login to start your session'
```

You should see: the login line again, and your own company name on the dashboard after you sign
in with the same password as before.

If you do not: the order is what matters. The config archive has to land before any container
starts, because MariaDB reads its passwords from .env the moment it initialises an empty data
directory. The application archive has to land after `docker compose up -d akaunting`, because
Docker only fills that volume from the image when it is empty, and the restart afterwards is
what puts the file ownership back. Understand the stakes before you skip this: that database is
what you file taxes from.

## 9. Updating later

Two things move separately here, and it surprises people. A newer image tag moves PHP, Apache
and the image's own copy of the application, but the copy that runs lives in the
`akaunting-html` volume, which Docker filled once and will not fill again. So these three
commands move the runtime and nothing else:

```bash
cd /srv/akaunting
docker compose pull
docker compose up -d
docker compose logs --tail 30 akaunting
```

You should see: Apache starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Image tags are
at https://hub.docker.com/r/akaunting/akaunting/tags and the releases behind them at
https://github.com/akaunting/akaunting/releases. Take all three backups from step 8 first, then
edit the image line in /srv/akaunting/compose.yml to the new tag and its digest. Akaunting
itself moves with the updater upstream documents, one console command:
`docker compose exec -T akaunting php artisan update:all`. Do that with a fresh backup and an
hour when nobody needs the books.

## 10. What will probably go wrong

The first two minutes look like a failed install. `docker compose ps` says the application
container is `Up`, curl returns nothing at all, and the log sits on
`Connecting to database akaunting@db:3306` while the entrypoint retries every five seconds. I
went hunting for the bug and there was not one: the entrypoint runs the whole installer before
Apache starts, so there is no half-built page to look at while it works. Give step 7's loop its
full 40 rounds. When it fails for real it fails loudly, with `Unable to find database!` after
30 seconds of retries, and that points at step 3.

## 11. Out of scope

- Do not configure SMTP. Akaunting runs without it, and the cost is that Email Invoice does
  nothing, so you send the PDF or the portal link yourself.
- Do not add a cron container or a scheduler service. Nothing here runs Laravel's scheduler, so
  recurring invoices and reminder mail never fire.
- Do not enter an Akaunting API key or install anything from the app store. Those apps are
  purchases tied to an akaunting.com account, and the double-entry ledger is one of them.
- Do not create a second company or extra user accounts. The licence grants production use for
  one company and two users.

334 lines · 14,985 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Akaunting 3.1.21, with the MariaDB it keeps the books in, under ~/selfhost/akaunting,
answering at http://localhost:8151.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install.
Akaunting answers at http://localhost:8151 and nowhere else, so the client portal is a
page only this computer opens: an invoice reaches a customer as a PDF they send. Akaunting is
also source-available, not open source, and its licence grants free production use for up to
two users, one company and one thousand invoices.

Detect the OS and measure:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print too, for step 2. This needs 2048 MB of RAM available and
10 GB free on the home disk, and both images publish amd64 and arm64. Under either floor, print
both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/akaunting/backups
ls -la ~/selfhost/akaunting
```

Assert: `backups`, owned by the user. There is no `data` folder: the books are rows in MariaDB,
the application lives in /var/www/html, and step 5 keeps both in Docker-managed volumes, so no
ownership fix is needed.

## 4. Secrets

Three secrets: the MariaDB password for the `akaunting` user, the MariaDB root password, and
the password the installer puts on the first account. Generate all three here, print none, and
keep them out of your summary and every log.

```bash
umask 077
cat > ~/selfhost/akaunting/.env <<EOF
APP_URL=http://localhost:8151
LOCALE=en-US
COMPANY_NAME=My Company
COMPANY_EMAIL=owner@example.com
ADMIN_EMAIL=owner@example.com
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
AKAUNTING_SETUP=true
EOF
chmod 600 ~/selfhost/akaunting/.env
umask 022
ls -l ~/selfhost/akaunting/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these lines run the same everywhere. The
account signs in as `owner@example.com`, an address that does not have to exist
because nothing here sends mail. On Windows those mode bits are advisory: NTFS does not enforce
them, and the user's account is the real boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/akaunting/compose.yml <<'EOF'
# Akaunting · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image README ... https://github.com/akaunting/docker/blob/master/README.md
#   entrypoint ..... https://github.com/akaunting/docker/blob/master/files/akaunting.sh
#   variables ...... https://github.com/akaunting/docker/blob/master/env/run.env.example
#
# Two services, run from ~/selfhost/akaunting/. Both data directories are named
# volumes rather than relative binds: MariaDB chowns /var/lib/mysql and the
# entrypoint chowns /var/www/html, and a home bind mount cannot grant either on
# Windows. 3.1.21 is the newest tag akaunting/docker has published, and 3.2.1
# has no image behind it. Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: akaunting-db
    restart: unless-stopped
    # Upstream's install page asks for utf8mb4_general_ci.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: akaunting
      MARIADB_USER: akaunting
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - akaunting-mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  akaunting:
    image: akaunting/akaunting:3.1.21@sha256:50940112be48a229a2f567dc50ace9886fe5b14e1fe33f0232e704d0fb96f29f
    container_name: akaunting
    restart: unless-stopped
    environment:
      # The <base href> on every page: this computer, port digits and all.
      APP_URL: ${APP_URL}
      LOCALE: ${LOCALE}
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: akaunting
      DB_USERNAME: akaunting
      DB_PASSWORD: ${DB_PASSWORD}
      DB_PREFIX: ""
      COMPANY_NAME: ${COMPANY_NAME}
      COMPANY_EMAIL: ${COMPANY_EMAIL}
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      # The installer runs while AKAUNTING_SETUP is set; step 7 deletes it.
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
      AKAUNTING_SETUP: ${AKAUNTING_SETUP:-}
    volumes:
      - akaunting-html:/var/www/html
    ports:
      # Loopback only: no other device on the wifi can reach 8151.
      - "127.0.0.1:8151:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  akaunting-mariadb:
  akaunting-html:
EOF
cd ~/selfhost/akaunting && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one published port, two named volumes, and no
default credential: upstream's example ships a published database password and
`me@company.com`, replaced in step 4.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule: no hostname to resolve, no public name for
a certificate to attest, nothing published beyond loopback to close. Browsers treat
http://localhost as a secure context, so pages needing crypto still work.

8151 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone. For a set of books that is the point. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/akaunting/compose.yml
```

Assert: that prints `1`, the one published port `- "127.0.0.1:8151:80"`. MariaDB publishes no
host port.

## 7. Start and verify

MariaDB initialises, then the entrypoint runs `php artisan install`: it writes the
application's .env inside the volume with a fresh `APP_KEY`, builds the schema, and creates the
company and the account. Apache starts a minute or two later.

```bash
cd ~/selfhost/akaunting
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8151/auth/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8151/auth/login | grep -o 'Login to start your session'
docker compose logs akaunting | grep -c 'Creating admin'
```

Assert all three, printing each: the loop ends on `200`; then
`Login to start your session`; then `1`, the installer's own line for the account it made. On
any miss, stop, run `docker compose logs --tail 60 akaunting` and say which step is the likely
cause: `Unable to find database!` is step 4. A running container is not success.

The first screen at http://localhost:8151/ redirects to /auth/login and shows the Akaunting
logo over the line `Login to start your session`, an `Email` box, a `Password` box and a
`Login` button.

STOP: tell the user to read the password with `grep ADMIN_PASSWORD ~/selfhost/akaunting/.env`,
put it in their password manager, sign in at http://localhost:8151/auth/login as
`owner@example.com`, rename the company under Settings, confirm the dashboard loads, and wait.
Do not continue until they confirm. The next block deletes this machine's copy.

Close the bootstrap out:

```bash
cd ~/selfhost/akaunting
sed -i -e '/^ADMIN_PASSWORD/d' -e '/^AKAUNTING_SETUP/d' ~/selfhost/akaunting/.env
docker compose up -d --force-recreate akaunting
sleep 45
docker compose logs akaunting | grep -c 'Creating admin' || true
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8151/auth/login
```

Assert both: the count that read `1` now prints `0`, and the status prints `200`. That `0` is
the security assert here: the replaced container starts Apache without running the installer
again. There is no reset mail, so the password manager entry is the recovery plan.

## 8. First backup and restore

Three artifacts: the dump holds the books; the application archive holds the volume's .env,
whose `APP_KEY` decrypts what Laravel encrypted, plus attachments; the config archive holds the
two files that rebuild the service around them.

```bash
cd ~/selfhost/akaunting
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction --no-tablespaces -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > ~/selfhost/akaunting/backups/akaunting-db-$(date +%F).sql.gz
docker compose exec -T akaunting tar -C /var/www/html -czf - .env storage modules > ~/selfhost/akaunting/backups/akaunting-app-$(date +%F).tar.gz
tar -C ~/selfhost/akaunting -czf ~/selfhost/akaunting/backups/akaunting-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/akaunting/backups/
```

Assert: all three exist, all three are non-empty, and print all three sizes.
`--single-transaction` snapshots a running InnoDB database, so nothing goes offline.

All three sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Have the user `cp` all three somewhere that leaves this computer, a sync folder or a
USB stick, and confirm they are listed there; in Git Bash a Windows drive is `/d/Backups`. If
they cannot, this install has no backup, and say so plainly.

To restore: untar the config archive into ~/selfhost/akaunting first, so .env is back before
any container starts, because MariaDB reads its passwords from it as it initialises. Then
`docker compose down -v`, `docker compose up -d db`, wait 30 seconds, pipe `gunzip -c` on the
dump into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d akaunting` to refill the volume from the image,
`docker compose exec -T akaunting tar -C /var/www/html -xzf - < backups/akaunting-app-<date>.tar.gz`,
and `docker compose restart akaunting`. Sign in and check an invoice is there.

## 9. Updating later

Two things move separately. A newer image tag moves PHP and Apache, but the application lives
in the `akaunting-html` volume, which Docker filled once and will not refill, so back up first,
edit the image line to the new tag and digest, then run:

```bash
cd ~/selfhost/akaunting
docker compose pull
docker compose up -d
docker compose logs --tail 30 akaunting
```

Tags are at https://hub.docker.com/r/akaunting/akaunting/tags. Akaunting itself moves with the
updater upstream documents: `docker compose exec -T akaunting php artisan update:all`.

## 10. What will probably go wrong

I closed the lid, came back next morning, opened the bookmark and got a connection error
that read like the whole install had gone. It had not. Docker Desktop had not started with the
session, so nothing was listening on 8151, and `restart: unless-stopped` acts only once the
Docker daemon is up. Turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/akaunting && docker compose up -d` before concluding it is broken.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not configure SMTP. Email Invoice then does nothing, and the user sends the PDF.
- Do not add a cron container or scheduler service. Nothing runs Laravel's scheduler here, so
  recurring invoices never fire.
- Do not enter an Akaunting API key or install app-store apps; those are purchases tied to an
  akaunting.com account.
compose.local.ymlthe services, pinned · local layout71 lines

authored from upstream docs, never pasted · 2,740 bytes

# Akaunting · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image README ... https://github.com/akaunting/docker/blob/master/README.md
#   entrypoint ..... https://github.com/akaunting/docker/blob/master/files/akaunting.sh
#   variables ...... https://github.com/akaunting/docker/blob/master/env/run.env.example
#
# Two services, run from ~/selfhost/akaunting/. Both data directories are named
# volumes rather than relative binds: MariaDB chowns /var/lib/mysql and the
# entrypoint chowns /var/www/html, and a home bind mount cannot grant either on
# Windows. 3.1.21 is the newest tag akaunting/docker has published, and 3.2.1
# has no image behind it. Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: akaunting-db
    restart: unless-stopped
    # Upstream's install page asks for utf8mb4_general_ci.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: akaunting
      MARIADB_USER: akaunting
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - akaunting-mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  akaunting:
    image: akaunting/akaunting:3.1.21@sha256:50940112be48a229a2f567dc50ace9886fe5b14e1fe33f0232e704d0fb96f29f
    container_name: akaunting
    restart: unless-stopped
    environment:
      # The <base href> on every page: this computer, port digits and all.
      APP_URL: ${APP_URL}
      LOCALE: ${LOCALE}
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: akaunting
      DB_USERNAME: akaunting
      DB_PASSWORD: ${DB_PASSWORD}
      DB_PREFIX: ""
      COMPANY_NAME: ${COMPANY_NAME}
      COMPANY_EMAIL: ${COMPANY_EMAIL}
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      # The installer runs while AKAUNTING_SETUP is set; step 7 deletes it.
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
      AKAUNTING_SETUP: ${AKAUNTING_SETUP:-}
    volumes:
      - akaunting-html:/var/www/html
    ports:
      # Loopback only: no other device on the wifi can reach 8151.
      - "127.0.0.1:8151:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  akaunting-mariadb:
  akaunting-html:

agent-readable mirror: /self-host/quickbooks-online.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned70 lines

authored from upstream docs, never pasted · 2,696 bytes

# Akaunting · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image README ... https://github.com/akaunting/docker/blob/master/README.md
#   entrypoint ..... https://github.com/akaunting/docker/blob/master/files/akaunting.sh
#   variables ...... https://github.com/akaunting/docker/blob/master/env/run.env.example
#
# Akaunting's Apache image and the MariaDB holding the books. /var/www/html is
# a named volume because the image ships the application there and chowns it;
# MariaDB chowns its own directory, so that one is the bind mount. 3.1.21 is the
# newest tag akaunting/docker has published, and 3.2.1 has no image behind it.
# Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: akaunting-db
    restart: unless-stopped
    # Upstream's install page asks for utf8mb4_general_ci.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: akaunting
      MARIADB_USER: akaunting
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - /srv/akaunting/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  akaunting:
    image: akaunting/akaunting:3.1.21@sha256:50940112be48a229a2f567dc50ace9886fe5b14e1fe33f0232e704d0fb96f29f
    container_name: akaunting
    restart: unless-stopped
    environment:
      # The <base href> on every page: the scheme and host Caddy answers on.
      APP_URL: ${APP_URL}
      LOCALE: ${LOCALE}
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: akaunting
      DB_USERNAME: akaunting
      DB_PASSWORD: ${DB_PASSWORD}
      DB_PREFIX: ""
      COMPANY_NAME: ${COMPANY_NAME}
      COMPANY_EMAIL: ${COMPANY_EMAIL}
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      # The installer runs while AKAUNTING_SETUP is set; step 7 deletes it.
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
      AKAUNTING_SETUP: ${AKAUNTING_SETUP:-}
    volumes:
      - akaunting-html:/var/www/html
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8151.
      - "127.0.0.1:8151:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  akaunting-html:
Caddyfilethe hostname and TLS28 lines

authored from upstream docs, never pasted · 1,031 bytes

# Akaunting · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/akaunting/docker/blob/master/env/run.env.example and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, the <base href> on every page, so the two stay equal.

<DOMAIN> {
	# The interface is HTML and JSON; PDFs are already compressed.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
	# No Content-Security-Policy: invoice templates are editable here, and
	# one written blind breaks a printed invoice rather than an attack.

	# 8151 is the loopback port compose publishes here, not a container
	# port, and not open in the firewall.
	reverse_proxy 127.0.0.1:8151
}
install.shthe same install, no agent170 lines

authored from upstream docs, never pasted · 8,078 bytes

#!/usr/bin/env bash
# Akaunting · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=books.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/akaunting/docker/blob/master/README.md
#   https://github.com/akaunting/docker/blob/master/files/akaunting.sh
#   https://github.com/akaunting/docker/blob/master/env/run.env.example
#   https://akaunting.com/hc/docs/on-premise/requirements/
#
# Three secrets are generated here, on this machine: the MariaDB password for the
# akaunting database user, the MariaDB root password, and the password put on the
# first account. All three go into /srv/akaunting/.env with mode 600 and none of
# them is ever printed.
#
# Akaunting is source-available, not open source. Its licence grants free
# production use for up to two users, one company and one thousand invoices.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/akaunting}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. books.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address the first account signs in with"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP under Apache plus MariaDB wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# No directory for the application: the image ships Akaunting inside
# /var/www/html and compose keeps that path in a named volume.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/mariadb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Read them later with
#   grep -E 'DB_PASSWORD|ADMIN_PASSWORD' /srv/akaunting/.env
# AKAUNTING_SETUP makes the container run `php artisan install` once. Step 6
# removes it so a later restart cannot run the installer a second time.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		APP_URL=https://${DOMAIN_HOST}
		LOCALE=en-US
		COMPANY_NAME=My Company
		COMPANY_EMAIL=${ADMIN_EMAIL}
		ADMIN_EMAIL=${ADMIN_EMAIL}
		DB_PASSWORD=$(openssl rand -hex 32)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
		ADMIN_PASSWORD=$(openssl rand -base64 24)
		AKAUNTING_SETUP=true
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-akaunting"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8151 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8151 and 3306 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# MariaDB initialises, then the entrypoint runs the installer: it writes the
# application's own .env inside the volume with a fresh APP_KEY, builds the
# schema, and creates the company and the account. Apache starts after that.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/auth/login"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/auth/login" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/auth/login answered ${code:-nothing}. Check: docker compose logs --tail 60 akaunting"

curl -sS "https://${DOMAIN_HOST}/auth/login" | grep -q 'Login to start your session' \
	|| die "the login page did not contain 'Login to start your session'. Check: docker compose logs --tail 60 akaunting"

created="$(docker compose logs akaunting | grep -c 'Creating admin' || true)"
[ "$created" = "1" ] || die "the installer created ${created} accounts, not 1. Stop and read: docker compose logs --tail 60 akaunting"

# Close the bootstrap: without AKAUNTING_SETUP the entrypoint starts Apache and
# nothing else, so no restart can build a second company or a second account.
sed -i -e '/^AKAUNTING_SETUP/d' "$APP_DIR/.env"
docker compose up -d --force-recreate akaunting
sleep 45
again="$(docker compose logs akaunting | grep -c 'Creating admin' || true)"
[ "$again" = "0" ] || die "the installer ran again after AKAUNTING_SETUP was removed. Stop and investigate."
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/auth/login" || true)"
[ "$code" = "200" ] || die "/auth/login answered ${code} after the restart"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction --no-tablespaces -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > "$APP_DIR/backups/akaunting-db-${STAMP}.sql.gz"
docker compose exec -T akaunting tar -C /var/www/html -czf - .env storage modules > "$APP_DIR/backups/akaunting-app-${STAMP}.tar.gz"
sudo tar -czf "$APP_DIR/backups/akaunting-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/akaunting-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/akaunting-app-${STAMP}.tar.gz" ] || die "the application archive is empty"

cat <<-DONE

	Akaunting is answering at https://${DOMAIN_HOST}/auth/login

	  1. Your password is in $APP_DIR/.env, mode 600. Read it with
	       grep ADMIN_PASSWORD $APP_DIR/.env
	     put it in your password manager, and sign in as ${ADMIN_EMAIL}.
	     It was not printed here. Nothing on this install sends mail, so
	     there is no reset link: that password manager entry is the plan.
	     Once it is saved, take the line off the server:
	       sed -i '/^ADMIN_PASSWORD/d' $APP_DIR/.env
	  2. Rename the company under Settings. Every invoice carries that name,
	     and it currently reads My Company.
	  3. The licence grants free production use for up to two users, one
	     company and one thousand invoices. The double-entry ledger, bank
	     feeds and inventory are separate paid apps from akaunting.com.
	  4. First backup written to $APP_DIR/backups: a database dump, an
	     application archive holding the volume's .env and attachments, and a
	     config archive. They are on the same disk as the data, which is not
	     a backup. Copy all three somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel QuickBooks Online.

  • Source-available, not open source. Akaunting is under the Business Source License 1.1: you may read and modify the code, but the free production grant stops at two users, one company and one thousand invoices, and it converts to GPLv3 four years after each version ships. One person billing their own clients sits inside that grant; a bookkeeper running books for several companies does not, and buys an on-premise plan.
  • Double-entry is a paid app. The free core gives you customers, vendors, invoices, bills, transactions, a client portal, a profit and loss and a tax summary. The chart of accounts, general ledger, trial balance and balance sheet are the Double-Entry app at $72 a year, so if your accountant asks for a balance sheet, that is a purchase rather than a setting.
  • No bank feeds. Connecting a bank is another paid app at $132 a year; without it you download a statement and import or type the transactions, which is the part of QuickBooks people miss first.
  • The image trails the source. 3.1.21 is the newest tag upstream's Docker repository has published, while the project has released 3.2.1, and the application lives in a Docker volume, so pulling a newer image moves PHP and Apache but not Akaunting. Moving Akaunting is a console command, `php artisan update:all`, run after a backup.
  • Nothing here runs the scheduler or sends mail, so recurring invoices do not create themselves and Email Invoice does nothing. You send the PDF or the portal link yourself until you add both, and payroll, inventory and projects are further paid apps.

Where this came from

“provided you do not use it in any of the following ways: (1) Use the Licensed Work with a total of more than two users or one company or one thousand invoices.”

  • Akaunting is licensed under the Business Source License 1.1, whose Additional Use Grant permits production use only up to two users, one company and one thousand invoices, with GPLv3 as the change licence four years after a version is published. source
  • The chart of accounts, general ledger, trial balance and balance sheet come from the Double-Entry app, which costs $72 a year or $756 once for an on-premise install. source
  • Connecting a bank account is the Bank Feeds app, a paid subscription of $132 a year on-premise that runs through Plaid or GoCardless. source
  • The official image's entrypoint runs `php artisan install` with the database and admin details from the environment when AKAUNTING_SETUP is true, so the browser setup wizard is never needed. source
  • Recurring transactions and invoice reminders are scheduled Laravel commands, so they only run where something runs the scheduler, and the official image runs Apache alone. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host QuickBooks Online?

    Not QuickBooks Online itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Akaunting. Invoices, bills, expenses and a client portal on your own domain, with the double-entry ledger sold as a paid app. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces QuickBooks Online?

    Akaunting. Invoices, bills, expenses and a client portal on your own domain, with the double-entry ledger sold as a paid app. The closest thing here to the shape of QuickBooks rather than one half of it: customers and vendors, invoices and bills, transactions and transfers, a client portal, a profit and loss and a tax summary, in two containers on your own domain. Two things have to be said in the same breath. Its licence is source-available, not open source, and the free production grant stops at two users, one company and one thousand invoices, which happens to be about the shape of a Simple Start subscription. And the double-entry half of the name, the chart of accounts, general ledger and balance sheet, is a $72-a-year app rather than part of the free core, with bank feeds another $132 a year. Pick it when you keep the books yourself and hand an accountant a P&L; do not pick it expecting a balance sheet on day one. Also evaluated and ranked below it: Invoice Ninja. The prompt on this page installs Akaunting only. Akaunting is BUSL-1.1-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to QuickBooks Online?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Akaunting itself is free and BUSL-1.1-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: QuickBooks Online Simple Start, $38/mo — $456 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Akaunting install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Akaunting on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Akaunting on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer Akaunting answers at http://localhost:8151 and nowhere else, so the client portal is a page only you can open and every invoice reaches a customer as a PDF you send yourself. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.