# Can I self-host QuickBooks Online?

**YES** — it's called Akaunting. ONE EVENING setup · ~1.5 hours to running · 2 GB RAM minimum · $38/mo you stop paying ($456/yr on the Simple Start plan).

Akaunting authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/quickbooks-online/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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:
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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
```

## Also evaluated

Ranked below Akaunting for this swap. The prompts above install Akaunting only.

- **Invoice Ninja** — 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 page this mirrors: https://caniselfhostit.com/self-host/quickbooks-online/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
