# Can I self-host BambooHR?

**YES** — it's called OrangeHRM. ONE EVENING setup · ~1.5 hours to running · 2 GB RAM minimum · $250/mo you stop paying ($3,000/yr on the Core plan, 25 seats assumed).

OrangeHRM authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/bamboohr/

## 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 OrangeHRM Starter 5.9 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point here. Architecture is a gate, not a preference: the
`orangehrm/orangehrm:5.9` tag publishes one image manifest and it is `linux/amd64`. OrangeHRM
needs 2048 MB of RAM available and 5 GB free on /srv, and the database grows with every uploaded
document, because attachments live in it as blobs, not on disk.

```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 the architecture is anything but `amd64`, print it and stop: there is no arm64 build to fall
back to. If available RAM is under 2048 MB or free disk under 5 GB, print both 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, and failed attempts spend a hidden rate limit.

## 2. Layout

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

Assert: `ls -la` shows `backups` owned by the login user and `mariadb` at mode `700` owned by
root. The MariaDB image chowns its own data directory on first start, so leave that alone. The
application gets no directory here: its image declares `/var/www/html` a `VOLUME` and step 4
gives it a named Docker volume, because a bind mount would lay an empty folder over it.

## 3. Secrets

Two: the MariaDB root password and the password of the `orangehrm` database user. Generate both
on the server. Do not print either, do not repeat them in your summary, do not log them. Hex
rather than base64, because Compose reads these back out of `.env`, where a `$` would be
interpolated and a `#` would start a comment.

```bash
umask 077
cat > /srv/orangehrm/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 24)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/orangehrm/.env
umask 022
ls -l /srv/orangehrm/.env
```

Assert: the file exists with mode `-rw-------`. `DB_PASSWORD` is not only a compose variable:
OrangeHRM's installer is a browser wizard with nothing behind it to configure from outside, so at
step 7 the user reads that value and types it in. Give them the command then, not the value now.
The root password is typed nowhere; MariaDB refuses to start without one of its root options.

## 4. compose.yml

```bash
cat > /srv/orangehrm/compose.yml <<'EOF'
# OrangeHRM Starter · the deterministic fallback. Authored by caniselfhostit
# from the upstream packaging, not copied from a repository:
#   image build ....... https://github.com/orangehrm/orangehrm/blob/v5.9/Dockerfile
#   supported engines . https://github.com/orangehrm/orangehrm/blob/v5.9/installer/config/system_requirements.php
#   mariadb image ..... https://hub.docker.com/_/mariadb
#
# OrangeHRM Starter 5.9 on Apache with PHP 8.3, and the MariaDB holding every
# employee record. The image has no configuration environment variables: its
# browser installer writes lib/confs/Conf.php inside /var/www/html, which is why
# that path is a named volume. The database values are what that installer asks
# for with `Existing Empty Database` chosen. Digests read on 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: orangehrm-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: orangehrm
      MARIADB_USER: orangehrm
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      # The bind mount goes here: MariaDB chowns its own data directory.
      - /srv/orangehrm/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 15s
      interval: 10s
      retries: 30
    # No `ports:` at all: 3306 is reachable only from the other container.

  orangehrm:
    image: orangehrm/orangehrm:5.9@sha256:d692780efbb118b1ede754cfb153057baecf4c4c5f84627621ed015cf837ac28
    platform: linux/amd64
    container_name: orangehrm
    restart: unless-stopped
    # OrangeHRM reads the HTTPS server variable, never X-Forwarded-Proto, and
    # that sets the cookie's Secure flag and the scheme on every redirect. Only
    # Caddy reaches this container, and only over https.
    command: ["apache2-foreground", "-c", "SetEnv HTTPS on"]
    volumes:
      # The application, lib/confs/Conf.php included. Losing it loses the
      # install, not the data: the data is in MariaDB.
      - orangehrm-app:/var/www/html
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8194.
      - "127.0.0.1:8194:80"
    depends_on:
      mariadb:
        condition: service_healthy

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

Assert: that prints `compose OK`. MariaDB creates the `orangehrm` database and its user on first
start and stops there, with no tables in it, which is the `Existing Empty Database` the wizard
asks about at step 7. That branch keeps the root credential out of a browser; the other needs a
user that can create databases and users.

## 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 here.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-orangehrm
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# OrangeHRM Starter · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://caddyserver.com/docs/automatic-https and
# https://github.com/orangehrm/orangehrm/blob/v5.9/src/plugins/orangehrmCorePlugin/config/CorePluginConfiguration.php
#
# Append to /etc/caddy/Caddyfile with <DOMAIN> replaced by this box's hostname.

<DOMAIN> {
	encode zstd gzip

	# HSTS earns its place: OrangeHRM reads the scheme off the server environment,
	# which the compose file's Apache line sets, and this is the other half of it.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "same-origin"
		-Server
	}

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

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-orangehrm, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it alone.

## 6. Firewall

Two ports, both Caddy's, and 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. 8194 is bound to 127.0.0.1 and 3306 is never published, so neither belongs here. Assert:
`ufw status verbose` prints `Status: active`, those three, and no rule for 8194 or 3306.

## 7. Start and verify

Read the block first. OrangeHRM 5.9 has no environment variable that creates an administrator and
no scriptable installer: its command line installer refuses non-interactive mode outright. Only a
person in a browser can finish this, and until one does the hostname serves a setup wizard to
whoever loads it first. The wizard cannot pass its database screen without the password in a
mode-600 file here, and a finished install refuses the installer for good. Both narrow the
window; neither closes it, so hand it over the moment it answers.

```bash
cd /srv/orangehrm
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sSL -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL https://<DOMAIN>/ | grep -c 'welcome-screen'
docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "SELECT 1" "$MARIADB_DATABASE"'
```

Assert all three and print what you received. The loop ends printing `200`. The grep prints `1`:
an uninstalled OrangeHRM redirects its root into the installer, and that page carries the
`welcome-screen` component in its markup. The query prints `1` under a `1` heading, which is the
credential the wizard is about to be handed. A `0` from the grep means either Caddy is reaching
something other than this container or somebody has already finished this wizard, and the second
means rebuilding the box, so stop either way. On any other miss run
`docker compose logs --tail 40 orangehrm` and name the likely earlier step.

Give the user these four:

- Database Configuration: pick `Existing Empty Database`, then host `mariadb`, port `3306`,
  database `orangehrm`, user `orangehrm`, password from
  `sudo grep DB_PASSWORD /srv/orangehrm/.env`.
- Leave `Enable Data Encryption` unticked. It writes a key file every later backup has to carry,
  or the encrypted columns come back unreadable.
- Admin User screen: untick the box offering to register the system with OrangeHRM. It is ticked
  by default, and ticked it posts their name, email, phone and organisation name to OrangeHRM.
- The admin password wants 8 characters or more, no spaces, and a lower-case letter, an
  upper-case letter, a digit and a symbol.

STOP: tell the user to open https://<DOMAIN>, complete the setup wizard, and say when they reach
the screen that reports the installation is complete.
Do not continue until they confirm.

Now prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' 'https://<DOMAIN>/installer/index.php/installer/database-config'
curl -sSL https://<DOMAIN>/ | grep -c 'auth-login'
```

Assert both and print the values. The first prints `502`: with the configuration file written
upstream refuses every installer screen, and that is the security assert here. The second prints
`1`, the login component the root now redirects to. Anything but `502` means the wizard did not
finish and this install is still claimable, so stop and send the user back.

## 8. First backup and restore

Three artifacts. The dump holds every employee, leave request, timesheet and uploaded document.
The confs archive holds `lib/confs/Conf.php`; the config archive rebuilds the service around
both.

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

Assert: all three exist, all three are non-empty, all three sizes printed. Nothing stops;
`--single-transaction` reads a consistent snapshot of the InnoDB tables. The MariaDB directory is
never archived: a copy of a live one is a corrupt database with a backup's name.

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

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

To restore, in order. `docker compose down -v` drops the application volume and leaves the
MariaDB bind mount alone. `sudo rm -rf /srv/orangehrm/mariadb`, recreate it as in step 2, untar
the config archive into /srv/orangehrm so `.env` is back first, then
`docker compose up -d mariadb` and wait for healthy. Pipe `gunzip -c` on the dump into
`docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`.
Put the configuration file back before Apache serves a request:
`docker compose run --rm --no-deps -T orangehrm tar -C /var/www/html -xzf - < backups/orangehrm-confs-<date>.tar.gz`,
which fills the fresh volume from the image and swaps the web server for `tar`. Then
`docker compose up -d`. Order matters: start the application first and it hands a setup wizard to
the internet in front of a database of staff records.

## 9. Updating later

Releases are at https://github.com/orangehrm/orangehrm/releases, image tags at
https://hub.docker.com/r/orangehrm/orangehrm/tags. Block 8's three backups are a prerequisite:
this is the one operation here that can lose data.

A newer image alone changes nothing: Docker fills a named volume from the image only when the
volume does not exist, so a pull on a new tag leaves 5.9 running in `orangehrm-app`. Upgrading
means taking the volume away. Edit the image line to the new tag and digest, then:

```bash
cd /srv/orangehrm
docker compose down
docker volume rm orangehrm_orangehrm-app
docker compose pull
docker compose up -d
```

The new code arrives with no `lib/confs/Conf.php`, so the site is a setup wizard again. Open it,
choose `Upgrading an Existing Installation`, give it step 7's database details, and pick the
version being upgraded from. Do not restore the confs archive into an upgrade: the new code
writes its own. Upstream's upgrader screen says to point it at a copy of the database, not the
original, because a failed migration does not roll back.

## 10. What will probably go wrong

The 1 MB ceiling on attachments will find you, and it will not look like a ceiling. OrangeHRM
sets its maximum attachment size as a constant in its own source, 1048576 bytes, with no
administration screen and no environment variable behind it, so when the form refuses a scanned
contract there is nothing here to turn up. I went looking for twenty minutes before reading the
source. Tell the user on day one: the answer is a second place to keep documents, not a setting.

## 11. Out of scope

- Do not configure SMTP or the Email Configuration screen. Leave and timesheet notifications are
  mail this install does not send, and the core loop works without them.
- Do not add a cron container. At 5.9 the only scheduled tasks upstream registers are LDAP user
  sync and workspace notification sends, both dormant unless turned on in the interface.
- Do not configure LDAP or an OpenID Connect provider. Both ship here, and both are a second
  system to keep working.
````

## 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 OrangeHRM Starter 5.9 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.

Read this before step 1, because it decides whether you want this install at all. OrangeHRM 5.9
has no environment variable that creates an administrator and no scriptable installer: its own
command line installer refuses non-interactive mode outright. The only thing that finishes this
install is you, in a browser, and between the moment the container answers and the moment you
finish that wizard, the hostname is showing a setup wizard to whoever loads it first. Step 7 is
written so that gap is minutes rather than an afternoon, and it ends by proving the door is shut.

## 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 `5` G free, `amd64`, and your server's IP
on the last line.

If you do not: `amd64` is not a preference here. The `orangehrm/orangehrm:5.9` tag on Docker Hub
publishes one image manifest and it is `linux/amd64`, so an `arm64` box has nothing to run and
this install stops here. An empty last line means the A record does not exist yet: add it, wait a
minute, run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that
does not resolve and failed attempts spend a rate limit you cannot see. The 2 GB floor is PHP
under Apache plus a MariaDB, and the disk figure matters more over time than it looks: OrangeHRM
stores uploaded documents in the database as blobs, so the database is where the growth lands.

## 2. Layout

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

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

If you do not: those two owners are deliberate. The MariaDB image chowns its own data directory
the first time it starts, so leaving that one to root is correct rather than sloppy. There is no
directory here for the application itself, and that is also deliberate: its image declares
`/var/www/html` as a `VOLUME`, step 4 gives it a named Docker volume, and a bind mount there
would put an empty folder over the application and leave you staring at an Apache error page.

## 3. Secrets

Two secrets, both generated on the server, neither of them printed here. `DB_PASSWORD` is the
password of the `orangehrm` database user, and `MARIADB_ROOT_PASSWORD` exists because the MariaDB
image refuses to start unless one of its three root options is set. Hex rather than base64,
because Compose reads these back out of `.env`, where a `$` would be interpolated and a `#` would
start a comment.

**Do not paste the contents of `.env`, either password, or any command output containing one into
this chat window.** The values below never leave your server unless you carry them out. You will
need `DB_PASSWORD` yourself at step 7, in a browser, and the command to read it is there.

```bash
umask 077
cat > /srv/orangehrm/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 24)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/orangehrm/.env
umask 022
ls -l /srv/orangehrm/.env
```

You should see: one line beginning `-rw-------`, owned by you.

If you do not: a mode with any group or other bits set means `umask 077` did not take, most often
because the heredoc was pasted without the first line. Delete the file and run the whole block
again from `umask 077`. If `openssl` is missing, `sudo apt-get install -y openssl` first; do not
substitute a password you thought of yourself, because you will reuse it somewhere else.

## 4. compose.yml

```bash
cat > /srv/orangehrm/compose.yml <<'EOF'
# OrangeHRM Starter · the deterministic fallback. Authored by caniselfhostit
# from the upstream packaging, not copied from a repository:
#   image build ....... https://github.com/orangehrm/orangehrm/blob/v5.9/Dockerfile
#   supported engines . https://github.com/orangehrm/orangehrm/blob/v5.9/installer/config/system_requirements.php
#   mariadb image ..... https://hub.docker.com/_/mariadb
#
# OrangeHRM Starter 5.9 on Apache with PHP 8.3, and the MariaDB holding every
# employee record. The image has no configuration environment variables: its
# browser installer writes lib/confs/Conf.php inside /var/www/html, which is why
# that path is a named volume. The database values are what that installer asks
# for with `Existing Empty Database` chosen. Digests read on 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: orangehrm-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: orangehrm
      MARIADB_USER: orangehrm
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      # The bind mount goes here: MariaDB chowns its own data directory.
      - /srv/orangehrm/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 15s
      interval: 10s
      retries: 30
    # No `ports:` at all: 3306 is reachable only from the other container.

  orangehrm:
    image: orangehrm/orangehrm:5.9@sha256:d692780efbb118b1ede754cfb153057baecf4c4c5f84627621ed015cf837ac28
    platform: linux/amd64
    container_name: orangehrm
    restart: unless-stopped
    # OrangeHRM reads the HTTPS server variable, never X-Forwarded-Proto, and
    # that sets the cookie's Secure flag and the scheme on every redirect. Only
    # Caddy reaches this container, and only over https.
    command: ["apache2-foreground", "-c", "SetEnv HTTPS on"]
    volumes:
      # The application, lib/confs/Conf.php included. Losing it loses the
      # install, not the data: the data is in MariaDB.
      - orangehrm-app:/var/www/html
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8194.
      - "127.0.0.1:8194:80"
    depends_on:
      mariadb:
        condition: service_healthy

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

You should see: `compose OK`.

If you do not: `docker compose config` prints the line it objected to. A complaint about
`DB_PASSWORD` or `MARIADB_ROOT_PASSWORD` being unset means you are not in `/srv/orangehrm`, since
Compose reads `.env` from the directory it runs in. A YAML indentation error means the heredoc
picked up your terminal's autoindent: paste it again into a fresh session. What this file sets up
is an empty `orangehrm` database with an `orangehrm` user on it, which is exactly the
`Existing Empty Database` the wizard asks about at step 7, and taking that branch is what keeps
the root credential out of a browser.

## 5. Caddy and TLS

Copy the Caddyfile first, because a syntax error in it takes down every other site on this box
and the copy is how you get them back. Replace `<DOMAIN>` in the block below with your real
hostname before you paste it: it appears twice, once in a comment and once as the site address.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-orangehrm
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# OrangeHRM Starter · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://caddyserver.com/docs/automatic-https and
# https://github.com/orangehrm/orangehrm/blob/v5.9/src/plugins/orangehrmCorePlugin/config/CorePluginConfiguration.php
#
# Append to /etc/caddy/Caddyfile with <DOMAIN> replaced by this box's hostname.

<DOMAIN> {
	encode zstd gzip

	# HSTS earns its place: OrangeHRM reads the scheme off the server environment,
	# which the compose file's Apache line sets, and this is the other half of it.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "same-origin"
		-Server
	}

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

You should see: `Valid configuration` from validate, and nothing at all from the reload.

If you do not: restore with
`sudo cp /etc/caddy/Caddyfile.before-orangehrm /etc/caddy/Caddyfile && sudo systemctl reload caddy`
and read what validate objected to. The usual cause is `<DOMAIN>` left literal, which Caddy reads
as a hostname it cannot get a certificate for. Replace it with your real hostname everywhere it
appears in the block above and run the whole thing again.

## 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`, and rules for 80/tcp, 443/tcp and 443/udp and nothing else new.

If you do not: an inactive firewall means Prompt Zero did not finish; `sudo ufw enable` and read
its warning about your session before you answer. If 8194 or 3306 appears in that list, remove it
with `sudo ufw delete allow 8194` or `sudo ufw delete allow 3306`. Neither belongs there: 8194 is
bound to 127.0.0.1 so only Caddy reaches it, and 3306 is never published to the host at all.

## 7. Start and verify

```bash
cd /srv/orangehrm
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sSL -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL https://<DOMAIN>/ | grep -c 'welcome-screen'
docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "SELECT 1" "$MARIADB_DATABASE"'
```

You should see: the loop ending on `200`, then `1`, then a `1` under a `1` heading.

If you do not: a loop that runs out still printing `000` is DNS or a certificate, so check
`dig +short <DOMAIN>` and `sudo journalctl -u caddy --no-pager -n 30`. A `502` means Caddy is up
and the container is not, so read `docker compose logs --tail 40 orangehrm`. A `0` from the grep
is the serious one: either Caddy is reaching something other than this container, or somebody has
already finished this wizard on your hostname, and the second case means rebuilding this box
rather than carrying on. If the query is refused, the wizard would be refused the same way, so
read `docker compose logs --tail 20 mariadb` before going further.

Now go and claim the install, and do it now rather than after lunch. Read all four of these
first, because two of them cannot be undone afterwards.

- Open https://<DOMAIN> in a browser. On the Database Configuration screen, select
  `Existing Empty Database`, then enter host `mariadb`, port `3306`, database name `orangehrm`,
  user `orangehrm`, and the password you read with `sudo grep DB_PASSWORD /srv/orangehrm/.env`.
- Leave `Enable Data Encryption` unticked. Ticking it writes a key file that every backup from
  then on has to carry, or the encrypted columns come back unreadable, and it is decided once.
- On the Admin User screen, untick the box offering to register your system with OrangeHRM. It is
  ticked by default, and ticked it posts your name, email address, telephone number, organisation
  name and a profile of this server to OrangeHRM's registration endpoint.
- Your admin password needs 8 characters or more, no spaces, and a lower-case letter, an
  upper-case letter, a digit and a symbol. Put it in your password manager before you submit it.
  Nothing on this server can reset it for you.

When the wizard reports the installation is complete, prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' 'https://<DOMAIN>/installer/index.php/installer/database-config'
curl -sSL https://<DOMAIN>/ | grep -c 'auth-login'
```

You should see: `502`, then `1`.

If you do not: `502` here is upstream's own answer, not an error. Once the configuration file
exists, OrangeHRM refuses every installer screen, for good, and that refusal is the security
assert in this step. Anything else on the first command means the wizard did not actually finish
and your hostname is still claimable by a stranger, so go back and finish it before you do
anything else. A `0` from the second means the root is not landing on the sign-in page; read
`docker compose logs --tail 40 orangehrm` before touching the Caddyfile.

## 8. First backup and restore

Three artifacts. The dump holds every employee, leave request, timesheet and uploaded document,
because attachments live in the database as blobs rather than on disk. The confs archive holds
`lib/confs/Conf.php`, the file the wizard wrote, which is the only thing between an installed
system and an open setup wizard. The config archive rebuilds the service around both.

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

You should see: three files, all three non-empty, with their sizes.

If you do not: a zero-byte dump means the database credentials in `.env` are not the ones the
wizard was given, so re-read them and check the wizard used `mariadb` as the host rather than
`localhost`. Nothing stops during this: `--single-transaction` reads a consistent snapshot of the
InnoDB tables while the site stays up. The MariaDB directory itself is deliberately not archived,
because a copy of a live InnoDB directory is a corrupt database wearing a backup's extension.

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

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

To restore, in this order. `docker compose down -v`, which drops the application volume and
leaves the MariaDB bind mount alone. `sudo rm -rf /srv/orangehrm/mariadb`, then recreate it as in
step 2. Untar the config archive into /srv/orangehrm so `.env` is back before anything starts.
`docker compose up -d mariadb`, wait until `docker compose ps` shows it healthy, then pipe
`gunzip -c` on the `.sql.gz` into
`docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`.
Put the configuration file back before Apache ever serves a request:
`docker compose run --rm --no-deps -T orangehrm tar -C /var/www/html -xzf - < backups/orangehrm-confs-<date>.tar.gz`,
which fills the fresh volume from the image and swaps the web server for `tar` in one step.
Finally `docker compose up -d`. That order is the whole disaster plan, and the reason for it is
step 7: start the application before that one file is back and it hands a setup wizard to the
internet in front of a database of staff records.

## 9. Updating later

Releases are listed at https://github.com/orangehrm/orangehrm/releases and the image tags at
https://hub.docker.com/r/orangehrm/orangehrm/tags. Take all three backups first. This is the one
operation on this page that can lose data, and upstream says so itself.

A newer image on its own changes nothing, and this surprises people. The application lives in the
`orangehrm-app` volume, and Docker fills a named volume from the image only when the volume does
not yet exist, so pulling a new tag leaves 5.9 running. Upgrading means taking the volume away.
Edit the image line in `/srv/orangehrm/compose.yml` to the new tag and its digest first, then:

```bash
cd /srv/orangehrm
docker compose down
docker volume rm orangehrm_orangehrm-app
docker compose pull
docker compose up -d
```

You should see: `docker volume rm` printing the volume name it removed, then a pull that actually
downloads layers, then both containers coming back up.

If you do not: `volume is in use` means the `down` did not finish, so run it again and check
`docker compose ps` is empty first. If the volume name is wrong, run `docker volume ls` and look
for your project directory name followed by `_orangehrm-app`.

The new code arrives with no `lib/confs/Conf.php`, so your site is a setup wizard again. Open it,
choose `Upgrading an Existing Installation` rather than a fresh install, give it the same
database details from step 7, and pick the version you are coming from in the dropdown. Do not
restore the confs archive into an upgrade: the point is that the new code writes its own. That
open window is step 7's claim race a second time, and it closes the same way. Upstream's own
upgrader screen tells you to point it at a copy of your database rather than the original,
because a failed migration does not roll back, so the careful version of this is to rehearse
against a restored dump somewhere else before you touch the live one.

## 10. What will probably go wrong

The 1 MB ceiling on attachments will find you, and it will not look like a ceiling. OrangeHRM
sets its maximum attachment size as a constant in its own source, 1048576 bytes, with no
administration screen and no environment variable behind it, and a fixed list of accepted file
types beside it. So the first time somebody drags a scanned contract onto an employee record and
the form refuses it, there is nothing on this server to turn up. I went looking for that setting
for twenty minutes before reading the source. Decide on day one where documents actually live,
because a 1 MB cap is a different product from the one you may think you are moving off.

## 11. Out of scope

- Do not configure SMTP or the Email Configuration screen. Leave and timesheet notifications are
  mail this install does not send, and the core loop works without them.
- Do not add a cron container. At 5.9 the only scheduled tasks upstream registers are LDAP user
  sync and workspace notification sends, both dormant unless you turn them on in the interface,
  so nothing is silently not running.
- Do not configure LDAP or an OpenID Connect provider. Both ship in this edition, and both are a
  second system to keep working.
````

## 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 OrangeHRM Starter 5.9 under ~/selfhost/orangehrm, answering at http://localhost:8194.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. An HR system exists so other people can file leave and look each other up, and this one
answers at http://localhost:8194: this computer, nobody else, nothing while the machine sleeps.
They get a private employee database with a leave calendar and a hiring pipeline.

Detect the OS and measure the machine:

```bash
uname -s
uname -m
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. OrangeHRM needs
2048 MB of RAM available and 5 GB free on the home disk; if either is under, print both and stop.
Do not install and hope. `uname -m` decides whether this runs here at all, because the
`orangehrm/orangehrm:5.9` tag publishes one manifest and it is `linux/amd64`. On `x86_64`,
continue. On `arm64` under `Darwin`, say Docker Desktop runs this Intel image under emulation,
which works and is slower. On `arm64` elsewhere, print it 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/orangehrm/backups
ls -la ~/selfhost/orangehrm
```

Assert: `ls -la` shows `backups`, and that is the whole tree. Both mounts are Docker named
volumes rather than folders, so there is nothing to open in Finder or Explorer and no ownership
fix to run on any of the three systems.

## 4. Secrets

Two: the MariaDB root password and the password of the `orangehrm` database user. Generate both
here. Do not print either and do not repeat them in your summary. Hex not base64: Compose reads
them back out of `.env`, where a `$` interpolates and a `#` starts a comment.

```bash
umask 077
cat > ~/selfhost/orangehrm/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 24)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/orangehrm/.env
umask 022
ls -l ~/selfhost/orangehrm/.env
```

Assert: the file exists with mode `-rw-------`. On Windows those mode bits are advisory and the
real boundary is the user's own account, which on a single-user machine is the boundary that
matters. `DB_PASSWORD` is not only a compose variable: step 7 has the user read it and type it
into the installer, so give the command there, not the value here.

## 5. compose.yml

```bash
cat > ~/selfhost/orangehrm/compose.yml <<'EOF'
# OrangeHRM Starter · the deterministic fallback for the local path. Authored
# by caniselfhostit from the upstream packaging, not copied from a repository:
#   image build ....... https://github.com/orangehrm/orangehrm/blob/v5.9/Dockerfile
#   supported engines . https://github.com/orangehrm/orangehrm/blob/v5.9/installer/config/system_requirements.php
#   mariadb image ..... https://hub.docker.com/_/mariadb
#
# Two services on the computer you are sitting at, and both mounts are named
# volumes rather than relative bind mounts: the image declares /var/www/html a
# VOLUME that Docker has to fill from the image, and MariaDB chowns its own data
# directory to a uid Docker Desktop cannot grant on a Windows bind mount. So
# nothing here shows up in Finder or Explorer. Digests read on 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: orangehrm-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: orangehrm
      MARIADB_USER: orangehrm
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - orangehrm-db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 15s
      interval: 10s
      retries: 30
    # No `ports:` at all: 3306 is reachable only from the other container.

  orangehrm:
    image: orangehrm/orangehrm:5.9@sha256:d692780efbb118b1ede754cfb153057baecf4c4c5f84627621ed015cf837ac28
    platform: linux/amd64
    container_name: orangehrm
    restart: unless-stopped
    volumes:
      # The application, lib/confs/Conf.php included. Losing it loses the
      # install, not the data: the data is in MariaDB.
      - orangehrm-app:/var/www/html
    ports:
      # Loopback only: no other device on the wifi can reach 8194.
      - "127.0.0.1:8194:80"
    depends_on:
      mariadb:
        condition: service_healthy

volumes:
  orangehrm-app:
  orangehrm-db-data:
EOF
cd ~/selfhost/orangehrm && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. MariaDB creates the `orangehrm` database and its user on first
start and stops there, with no tables in it: the `Existing Empty Database` step 7's wizard asks
about.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision: no hostname to
resolve, a certificate attests a public name nothing here has, nothing published past loopback.
Confirm the binding:

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

Assert: that prints `1`, the one published port, `- "127.0.0.1:8194:80"`. 8194 answers on this
computer only: not the user's phone, not a laptop on the wifi, nobody on the internet. That is
most of what this path costs, because the self-service half of the product has nobody to serve.
Browsers treat http://localhost as a secure context, so sign-in works without TLS.

## 7. Start and verify

OrangeHRM 5.9 has no environment variable that creates an administrator and no scriptable
installer, so only a person in a browser finishes this. Here that person is already at the
machine.

```bash
cd ~/selfhost/orangehrm
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sSL -o /dev/null -w '%{http_code}' http://localhost:8194/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL http://localhost:8194/ | grep -c 'welcome-screen'
docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "SELECT 1" "$MARIADB_DATABASE"'
```

Assert all three and print what you received. The loop ends printing `200`. The grep prints `1`:
an uninstalled OrangeHRM redirects its root into the installer, and that page carries the
`welcome-screen` component. The query prints `1` under a `1` heading. On a miss run
`docker compose logs --tail 40 orangehrm`; if `port is already allocated` came back, find what
holds 8194 (`lsof -nP -iTCP:8194 -sTCP:LISTEN`, or `netstat -ano | findstr :8194`). Under
emulation the first page can take half a minute.

Give the user these two first:

- Database Configuration: pick `Existing Empty Database`, then host `mariadb`, port `3306`,
  database `orangehrm`, user `orangehrm`, password from
  `grep DB_PASSWORD ~/selfhost/orangehrm/.env`. Leave `Enable Data Encryption` unticked; it
  writes a key file every later backup has to carry.
- Admin User screen: untick the box offering to register the system with OrangeHRM. It is ticked
  by default, and ticked it posts their name, email, phone number and organisation name off this
  machine, the only thing here that leaves it. The admin password wants 8 characters or more, no
  spaces, and a lower-case letter, an upper-case letter, a digit and a symbol.

STOP: tell the user to open http://localhost:8194, complete the setup wizard, and say when they
reach the screen that reports the installation is complete.
Do not continue until they confirm.

Confirm the installer is closed:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' 'http://localhost:8194/installer/index.php/installer/database-config'
curl -sSL http://localhost:8194/ | grep -c 'auth-login'
```

Assert both and print the values. The first prints `502`: with the configuration file written
upstream refuses every installer screen, for good. The second prints `1`, the login component the
root now redirects to. Anything but `502` means the wizard did not finish.

## 8. First backup and restore

Three artifacts. Two come out of Docker, because neither mount is a folder you can open: the
dump holds every employee, leave request, timesheet and document, and the confs archive holds
`lib/confs/Conf.php`. The third holds compose.yml and `.env`.

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

Assert: all three exist, all three are non-empty, all three sizes printed.

Those archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for somewhere that leaves this computer, a folder
their sync service watches or a USB stick, and copy them there with `cp`. In Git Bash a Windows
drive is `/d/Backups`, not `D:\Backups`. Assert: the user confirms the three names are there. If
they have nowhere, say plainly that this install has no backup.

To restore, in order. `docker compose down -v` drops both volumes. Untar the config archive into
~/selfhost/orangehrm so `.env` is back first, then `docker compose up -d mariadb` and wait for
healthy. Pipe `gunzip -c` on the dump into
`docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`.
Put the configuration file back before Apache serves a request:
`docker compose run --rm --no-deps -T orangehrm tar -C /var/www/html -xzf - < backups/orangehrm-confs-<date>.tar.gz`,
which fills the fresh volume and swaps the web server for `tar`. Then `docker compose up -d`.

## 9. Updating later

Releases are at https://github.com/orangehrm/orangehrm/releases, image tags at
https://hub.docker.com/r/orangehrm/orangehrm/tags. Take step 8's backups first: this is the one
operation here that can lose data. A newer image alone changes nothing, because Docker fills a
named volume from the image only when it does not exist. Edit the image line to the new tag and
digest, then take the volume away:

```bash
cd ~/selfhost/orangehrm
docker compose down
docker volume rm orangehrm_orangehrm-app
docker compose pull
docker compose up -d
```

The new code arrives with no `lib/confs/Conf.php`, so the site is a setup wizard again. Open it,
choose `Upgrading an Existing Installation`, give it step 7's database details, and pick the
version upgraded from. Do not restore the confs archive: the new code writes its own.

## 10. What will probably go wrong

I rebooted, opened http://localhost:8194 out of habit, got nothing at all, and for a minute
thought the install had eaten itself. Docker Desktop had not started with the machine, so neither
container existed to answer, and a browser with nothing on the other end looks exactly like a
broken install. Turn on Docker Desktop's start-at-login setting, and after any reboot run
`cd ~/selfhost/orangehrm && docker compose up -d` before believing an empty page.

## 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 rebind 8194 to 0.0.0.0 so colleagues on the wifi can reach it. That puts a database of
  personnel records on every network this machine joins.
- Do not configure SMTP, LDAP or an OpenID Connect provider. All three exist here, and each is
  another system to keep working.
````

## docker-compose.yml

```yaml
# OrangeHRM Starter · the deterministic fallback. Authored by caniselfhostit
# from the upstream packaging, not copied from a repository:
#   image build ....... https://github.com/orangehrm/orangehrm/blob/v5.9/Dockerfile
#   supported engines . https://github.com/orangehrm/orangehrm/blob/v5.9/installer/config/system_requirements.php
#   mariadb image ..... https://hub.docker.com/_/mariadb
#
# OrangeHRM Starter 5.9 on Apache with PHP 8.3, and the MariaDB holding every
# employee record. The image has no configuration environment variables: its
# browser installer writes lib/confs/Conf.php inside /var/www/html, which is why
# that path is a named volume. The database values are what that installer asks
# for with `Existing Empty Database` chosen. Digests read on 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: orangehrm-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: orangehrm
      MARIADB_USER: orangehrm
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      # The bind mount goes here: MariaDB chowns its own data directory.
      - /srv/orangehrm/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 15s
      interval: 10s
      retries: 30
    # No `ports:` at all: 3306 is reachable only from the other container.

  orangehrm:
    image: orangehrm/orangehrm:5.9@sha256:d692780efbb118b1ede754cfb153057baecf4c4c5f84627621ed015cf837ac28
    platform: linux/amd64
    container_name: orangehrm
    restart: unless-stopped
    # OrangeHRM reads the HTTPS server variable, never X-Forwarded-Proto, and
    # that sets the cookie's Secure flag and the scheme on every redirect. Only
    # Caddy reaches this container, and only over https.
    command: ["apache2-foreground", "-c", "SetEnv HTTPS on"]
    volumes:
      # The application, lib/confs/Conf.php included. Losing it loses the
      # install, not the data: the data is in MariaDB.
      - orangehrm-app:/var/www/html
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8194.
      - "127.0.0.1:8194:80"
    depends_on:
      mariadb:
        condition: service_healthy

volumes:
  orangehrm-app:
```

## compose.local.yml

```yaml
# OrangeHRM Starter · the deterministic fallback for the local path. Authored
# by caniselfhostit from the upstream packaging, not copied from a repository:
#   image build ....... https://github.com/orangehrm/orangehrm/blob/v5.9/Dockerfile
#   supported engines . https://github.com/orangehrm/orangehrm/blob/v5.9/installer/config/system_requirements.php
#   mariadb image ..... https://hub.docker.com/_/mariadb
#
# Two services on the computer you are sitting at, and both mounts are named
# volumes rather than relative bind mounts: the image declares /var/www/html a
# VOLUME that Docker has to fill from the image, and MariaDB chowns its own data
# directory to a uid Docker Desktop cannot grant on a Windows bind mount. So
# nothing here shows up in Finder or Explorer. Digests read on 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: orangehrm-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: orangehrm
      MARIADB_USER: orangehrm
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - orangehrm-db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 15s
      interval: 10s
      retries: 30
    # No `ports:` at all: 3306 is reachable only from the other container.

  orangehrm:
    image: orangehrm/orangehrm:5.9@sha256:d692780efbb118b1ede754cfb153057baecf4c4c5f84627621ed015cf837ac28
    platform: linux/amd64
    container_name: orangehrm
    restart: unless-stopped
    volumes:
      # The application, lib/confs/Conf.php included. Losing it loses the
      # install, not the data: the data is in MariaDB.
      - orangehrm-app:/var/www/html
    ports:
      # Loopback only: no other device on the wifi can reach 8194.
      - "127.0.0.1:8194:80"
    depends_on:
      mariadb:
        condition: service_healthy

volumes:
  orangehrm-app:
  orangehrm-db-data:
```

## Caddyfile

```text
# OrangeHRM Starter · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://caddyserver.com/docs/automatic-https and
# https://github.com/orangehrm/orangehrm/blob/v5.9/src/plugins/orangehrmCorePlugin/config/CorePluginConfiguration.php
#
# Append to /etc/caddy/Caddyfile with <DOMAIN> replaced by this box's hostname.

<DOMAIN> {
	encode zstd gzip

	# HSTS earns its place: OrangeHRM reads the scheme off the server environment,
	# which the compose file's Apache line sets, and this is the other half of it.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "same-origin"
		-Server
	}

	# 8194 is the loopback port compose publishes here, not a container port and
	# not open in the firewall.
	reverse_proxy 127.0.0.1:8194
}
```

## install.sh

```bash
#!/usr/bin/env bash
# OrangeHRM Starter · 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=hr.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream packaging:
#   https://github.com/orangehrm/orangehrm/blob/v5.9/Dockerfile
#   https://github.com/orangehrm/orangehrm/blob/v5.9/installer/config/routes.yaml
#   https://github.com/orangehrm/orangehrm/blob/v5.9/installer/config/system_requirements.php
#   https://github.com/orangehrm/orangehrm/blob/v5.9/installer/client/src/pages/DatabaseConfigScreen.vue
#
# Two secrets are generated here, on this machine: the MariaDB root password and
# the password of the orangehrm database user. Both go into /srv/orangehrm/.env
# with mode 600 and neither is ever printed. You will need the second one: the
# browser installer asks you to type it in.
#
# This script cannot finish the install, because only a browser can. OrangeHRM
# 5.9 has no environment variables and no scriptable installer: its command line
# installer refuses non interactive mode outright. The script stops with the
# setup wizard open and tells you to go and claim it. Until you do, whoever
# loads the hostname first is looking at your setup wizard.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/orangehrm}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

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

# --- 1. Refuse to start on a machine that is not ready -----------------------
#
# amd64 is a hard gate, not a preference. The 5.9 tag on Docker Hub publishes a
# single manifest and it is linux/amd64.

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. hr.example.com"
case "$DOMAIN_HOST" in
	*/*) die "DOMAIN_HOST is a hostname, not a URL: no scheme and no trailing slash" ;;
esac
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"

arch="$(dpkg --print-architecture)"
[ "$arch" = "amd64" ] || die "this server is ${arch}; orangehrm/orangehrm:5.9 publishes linux/amd64 only. Use an amd64 server."

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP-Apache plus MariaDB wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 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 ----------------------------------------------------
#
# mariadb is mode 700 and left to root because the MariaDB image chowns its own
# data directory on first start. The application has no directory here at all:
# it lives in a named Docker volume, because its image declares /var/www/html as
# a VOLUME and an empty bind mount there would hide the application.

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 two secrets, on the server ------------------------------
#
# Hex rather than base64 for both. These values are read back by Compose out of
# .env, where a `$` would be interpolated and a `#` would start a comment, and
# one of them gets typed into a browser field by a human. Read them later with
#   sudo grep -E 'DB_PASSWORD|MARIADB_ROOT_PASSWORD' /srv/orangehrm/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		DB_PASSWORD=$(openssl rand -hex 24)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 24)
	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-orangehrm"
	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 8194 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; 8194 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 an empty orangehrm database and its user on first start,
# which is the `Existing Empty Database` the wizard is about to be pointed at.
# Nothing else happens until a human opens a browser.

docker compose pull
docker compose up -d

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

# The root of an uninstalled OrangeHRM redirects into the installer, which
# renders a Vue page whose server-side markup carries the component name. Its
# absence means either Caddy is reaching something else or a previous run has
# already been claimed.
curl -sSL "https://${DOMAIN_HOST}/" | grep -q 'welcome-screen' \
	|| die "https://${DOMAIN_HOST}/ is not showing the setup wizard. If someone already finished it, treat this box as compromised and rebuild."

# The database has to be reachable from the application container under the
# hostname the wizard will be given, or the wizard cannot get past screen three.
docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "SELECT 1" "$MARIADB_DATABASE"' >/dev/null \
	|| die "the orangehrm database user cannot log in. Check: docker compose logs --tail 40 mariadb"

# --- 7. The first backup, of what exists right now ---------------------------
#
# There is no database dump yet, because there is no schema yet: the wizard
# writes it. What exists and cannot be regenerated is .env, and the archive
# below is the only copy of it that is not on the live disk.

STAMP="$(date +%Y%m%d-%H%M%S)"
sudo tar -czf "$APP_DIR/backups/orangehrm-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/orangehrm-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	OrangeHRM Starter 5.9 is answering at https://${DOMAIN_HOST}

	  1. Do this now, before anything else: open
	       https://${DOMAIN_HOST}/
	     and complete the setup wizard. Until you do, that is a setup wizard for
	     whoever loads the hostname first. Once one install finishes, the
	     installer answers 502 to every screen and can never be run again.
	  2. On the Database Configuration screen choose "Existing Empty Database",
	     then enter host mariadb, port 3306, database orangehrm, user orangehrm,
	     and the password from
	       sudo grep DB_PASSWORD $APP_DIR/.env
	     Leave "Enable Data Encryption" unticked: it writes a key file that every
	     backup then has to carry or the encrypted columns are unreadable.
	  3. On the Admin User screen, untick the box that offers to register your
	     system with OrangeHRM. Ticked, it posts your name, email, phone number,
	     organisation name and a profile of this server to OrangeHRM. It is
	     ticked by default. Your admin password needs 8 characters, no spaces,
	     and a lower-case letter, an upper-case letter, a digit and a symbol.
	  4. When the wizard says it is done, prove the installer is shut:
	       curl -sS -o /dev/null -w '%{http_code}' 'https://${DOMAIN_HOST}/installer/index.php/installer/database-config'; echo
	     That prints 502: upstream refuses every installer screen once the
	     configuration file exists. Anything else means the wizard did not
	     finish and the hostname is still claimable. Then take the real
	     backup, which this script could not:
	       cd $APP_DIR
	       docker compose exec -T mariadb sh -c 'exec mariadb-dump -u"\$MARIADB_USER" -p"\$MARIADB_PASSWORD" --single-transaction "\$MARIADB_DATABASE"' | gzip > backups/orangehrm-db-\$(date +%F).sql.gz
	       docker compose exec -T orangehrm tar -C /var/www/html -czf - lib/confs > backups/orangehrm-confs-\$(date +%F).tar.gz
	  5. Backups are on the same disk as the data, which is not a backup. Copy
	     them somewhere else tonight.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/bamboohr/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
