Can I self-host BambooHR?
YES · ONE EVENING— setup effort 2 of 4YES — it's called OrangeHRM. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $250 a month you stop paying BambooHR — $3,000 a year on the Core plan, 25 seats assumed.
Why people pay for BambooHR
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
BambooHR sells the feeling that HR admin is handled. One employee record that payroll, benefits, time off and performance all read from, an onboarding checklist that runs itself, and reporting a founder can actually use, wrapped in an interface that does not need training. That coherence is genuinely hard to build and harder to keep. What you rent alongside it is a bill that grows with headcount whether or not anyone logs in: it is priced per employee per month, the plan ladder puts performance management on the second rung and benchmarking on the third, and payroll, benefits administration, time and attendance and global employment are four separately quoted add-ons beside it. Every plan button on the pricing page says Get Quote.
| Plan | List price | What it buys |
|---|---|---|
| Corethe plan this page prices against | $10/mo per seat | $10 USD per employee per month for companies over 25 employees. Employee records, custom reports, workflows, documents, an AI assistant over your own HR data. Companies of 25 or fewer pay a flat rate starting at $250 USD/mo instead. |
| Pro | $17/mo per seat | $17 USD per employee per month. Everything in Core plus performance management, 360 review cycles and 1:1s, and an AI assistant that can also answer from your policies and handbooks. |
| Elite | $25/mo per seat | $25 USD per employee per month. Everything in Pro plus benchmark data and analysis, AI-powered data insights, and the premium service items: administrator training sessions, data integrity hours, extra implementation days. |
Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: high
Replaced by OrangeHRM
One project, named before the prompt, so you know what you are about to install.
The employee record, the leave calendar and the timesheet on your own server, with no per-head bill attached to them.
The only honest answer here, and it is a narrower one than BambooHR. OrangeHRM Starter is GPL-3.0 and self-hostable in full, with the employee record, leave, time and attendance, recruitment, performance, a corporate directory and reporting in the box, plus LDAP and OpenID Connect sign-in that most vendors would sell you. What it does not have, by its own vendor's comparison, is onboarding, training, surveys, career development, request desk, rostering and discipline, all of which sit in the quote-only Advanced edition, and there is no payroll in either. Two containers, a MariaDB you back up, and an install that ends with a browser wizard you have to claim yourself, because the command line installer refuses unattended mode. Attachments are capped at 1 MB by a constant in the source, which matters if documents were the reason you were leaving.
The swap
You'd run
OrangeHRM
ONE EVENING · ~90 min to running · 2048 MB RAM
BambooHR Core · 25 seats assumed · vendor list price · checked 2026-08-14 · source
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same OrangeHRM: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
322 lines · 14,990 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing 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.315 lines · 14,998 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.compose.local.ymlthe services, pinned · local layout53 lines
# 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:agent-readable mirror: /self-host/bamboohr.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned57 lines
# 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:Caddyfilethe hostname and TLS25 lines
# 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.shthe same install, no agent183 lines
#!/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.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel BambooHR.
- You are installing the free rung of a commercial ladder, and the vendor is candid about where it stops. OrangeHRM's own pricing page gives Starter HR administration, employee records, leave, time tracking, recruitment, performance and reporting, and lists Onboarding, Roster, Request Desk, Career Development, Training, Surveys, Employee Voice and Discipline under Advanced, which is quote-only per employee per month. There is no payroll module in either: payroll appears only as an integration. The 5.9 source tree matches that split, so this is a boundary you can read rather than one you discover in month three.
- Attachments have a 1 MB ceiling with nothing behind it. The limit is a constant in the source, the accepted file types are a fixed list beside it, and neither has an administration screen or an environment variable. If your reason for leaving a hosted HR product is its document store, this is not that.
- amd64 only. The 5.9 tag on Docker Hub publishes a single image manifest, linux/amd64, so an arm64 VPS or an arm64 Linux desktop cannot run this at all, and an Apple Silicon Mac runs it under Docker Desktop's emulation rather than natively.
- The first person to load the hostname owns the wizard. There is no admin seeded from a file, and the command line installer refuses unattended mode, so the install ends with a browser step you have to take yourself. The database password gates the wizard's third screen and a finished install refuses the installer for good, which narrows that window rather than removing it. The same wizard ticks a box by default that posts your name, email, telephone number and organisation name to OrangeHRM, and you untick it.
- The release cadence is real but slow, and worth stating rather than smoothing over. 5.9 landed on 2026-06-28 and 5.8.1 in April 2026, but 5.7 sat from July 2024 until 5.8 arrived in November 2025. Upgrades are also not a tag bump: the application lives in a Docker volume and moving versions means dropping that volume and running upstream's upgrader against the database by hand.
Where this came from
“public const MAX_ATTACHMENT_SIZE = 1048576; // 1 MB”
- OrangeHRM Starter is GPL-3.0 at the pinned tag, with the full licence text at the root of the tree and no separate premium directory carved out of it. source
- The image carries no configuration environment variables at all: it downloads the release zip, serves it from /var/www/html under Apache, and declares that directory a VOLUME. source
- The command line installer refuses to run unattended, answering `Not supported non interactive mode.` and exiting, so the browser wizard is the only way to create the first administrator. source
- Once the configuration file exists, every installer screen except the completion page answers HTTP 502, which is why a finished install cannot be re-run by a stranger. source
- The wizard's Database Configuration screen offers `New Database` or `Existing Empty Database`, and only the first asks for a privileged user that can create databases and database users. source
- OrangeHRM prices Starter as free and Advanced on a per-employee per-month quote, with Onboarding, Roster, Request Desk, Career Development, Training, Surveys, Employee Voice and Discipline listed only on the Advanced plan. source
- Employee attachments are stored in the database as blob columns rather than as files on disk, so the SQL dump is where uploaded documents live. source
- The session cookie's Secure flag is set from the request's own scheme, which the application reads from the server environment and never from X-Forwarded-Proto, so a reverse proxy has to say so on the Apache side. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host BambooHR?
Not BambooHR itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is OrangeHRM. The employee record, the leave calendar and the timesheet on your own server, with no per-head bill attached to them. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces BambooHR?
OrangeHRM. The employee record, the leave calendar and the timesheet on your own server, with no per-head bill attached to them. The only honest answer here, and it is a narrower one than BambooHR. OrangeHRM Starter is GPL-3.0 and self-hostable in full, with the employee record, leave, time and attendance, recruitment, performance, a corporate directory and reporting in the box, plus LDAP and OpenID Connect sign-in that most vendors would sell you. What it does not have, by its own vendor's comparison, is onboarding, training, surveys, career development, request desk, rostering and discipline, all of which sit in the quote-only Advanced edition, and there is no payroll in either. Two containers, a MariaDB you back up, and an install that ends with a browser wizard you have to claim yourself, because the command line installer refuses unattended mode. Attachments are capped at 1 MB by a constant in the source, which matters if documents were the reason you were leaving. OrangeHRM is GPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to BambooHR?
2048 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. OrangeHRM itself is free and GPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: BambooHR Core, $250/mo — $3,000 a year, 25 seats assumed.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the OrangeHRM install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run OrangeHRM on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same OrangeHRM on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: An HR system that answers only at http://localhost:8194 is a private database: nobody else can request leave, file a timesheet or look anyone up in it, and it is unreachable whenever this computer is asleep. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.