- Docker Install Walkthrough
- What you'll need before you start
- Phase 1 — Server prep
- Phase 2 — Free port 80
- Phase 3 — DNS + TLS cert
- Phase 4 — Build the Bizuno image
- Phase 5 — Configure the compose stack
- Phase 6 — Bring up the stack
- Phase 7 — Verify + run the Bizuno installer
- Common gotchas
- Day-2: backups + updates
- Alternative: Let's Encrypt instead of a wildcard cert
- See also
Docker Install Walkthrough #
A complete, copy-pasteable walkthrough for deploying Bizuno on a
dedicated Linux server using the production Docker stack
(build/docker/production/).
This is the right path when:
- You’re running Bizuno on a Linux server with no other web app
competing for ports 80/443. - You have (or can get) a TLS cert for the hostname Bizuno will
serve at — either a wildcard cert from your CA or Let’s Encrypt. - You don’t want or need a hosting control panel (ISPConfig, Plesk,
cPanel) managing the box.
If you instead want Bizuno to share a server with other sites managed
by an existing control panel, see the reverse-proxy patterns in the
top-level build/docker/README.md
instead.
End state: https://your.hostname/ serves Bizuno, TLS terminated by
Caddy, MariaDB persisted on a named Docker volume, three containers
running under docker compose. Roughly 30 minutes start to finish on
a fresh server.
What you’ll need before you start #
- A Linux server — Debian 13 (Trixie) used in this walkthrough.
Ubuntu 22.04+ / RHEL 9+ / Rocky 9+ work the same way with minor
command tweaks. ~1GB RAM minimum, 4GB recommended; 10GB disk for
the base + a year of light use. - SSH access as a user that can become root (
sudoor the root
password / key — we’ll set up sudo if it’s missing). - A hostname with a DNS A-record pointed at the server’s public
IP. (Or your local/etc/hostsoverride for pre-DNS testing — see
the LAN/WAN trick under Phase 3.) - A TLS cert + private key for that hostname. A wildcard cert
from any CA (Sectigo, DigiCert, etc.) covering the parent domain
works. Or skip this and let Caddy provision Let’s Encrypt
automatically — see “Alternative: Let’s Encrypt” at the end. - Ports 80 + 443 reachable from wherever you’ll test from. (Your
public internet for real users; your LAN for initial testing via
/etc/hostsoverride.)
Phase 1 — Server prep #
SSH in as your user:
ssh yo******@**ur.server
1.1 Verify the OS + sudo #
. /etc/os-release && echo "OS: $PRETTY_NAME / codename: $VERSION_CODENAME"
sudo -v && echo "sudo works"
If sudo errors with “may not run sudo” — you need root access to fix
this once. Either log in via su - (with the root password) or SSH
directly as root, then:
usermod -aG sudo youruser
exit
ssh yo******@**ur.server # re-login so the group change applies
sudo -v && echo "sudo works"
Note:
usermodlives in/usr/sbin/which isn’t on a regular
user’s$PATH. Always run it from a root shell, not from an
unprivileged shell.
1.2 Refresh apt + install basic tooling #
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y ca-certificates curl gnupg git
1.3 Install Docker Engine + Compose #
Add Docker’s official apt repo (NOT the distro’s old docker.io
package — it’s years behind and ships the legacy docker-compose
Python binary instead of the modern docker compose subcommand):
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/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/debian \
$(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
On Ubuntu, swap
linux/debian→linux/ubuntuin both URLs.
1.4 Allow your user to run Docker without sudo #
sudo usermod -aG docker youruser
exit # close session so group change takes effect
ssh yo******@**ur.server # log back in
groups | tr ' ' '\n' | grep docker # should print: docker
docker version # client + server versions
docker compose version # Compose v2.x
docker run --rm hello-world # smoke test
If the smoke test completes (pulls image, prints greeting, exits) →
Docker is working.
Phase 2 — Free port 80 #
Fresh Debian (and many other distros) ships with Apache and
CUPS installed by default, both listening on ports. Caddy can’t
bind to 80/443 while Apache holds them.
2.1 Check what’s listening #
sudo ss -tlnp | grep -E ':80|:443'
If you see apache2, nginx, or anything else on those ports, it
needs to go.
2.2 Confirm nothing important is being served #
# Is ISPConfig or another control panel managing this server?
ls -la /usr/local/ispconfig 2>/dev/null && echo "ISPConfig present" || echo "no ISPConfig"
ls /var/www/clients/ 2>/dev/null | head
# What vhosts is apache2 serving?
sudo apache2ctl -S 2>&1 | head -20
If you see live sites in /var/www/clients/ or ISPConfig present →
this server is in active use; don’t proceed with removal. Move
sites elsewhere first or pick a different server.
If you see only Debian’s default vhost serving /var/www/html/
(“It works!” placeholder) → safe to remove.
2.3 Remove apache2 + cups #
sudo systemctl stop apache2 cups
sudo systemctl disable apache2 cups
sudo apt remove -y apache2 apache2-data apache2-utils cups
sudo apt autoremove -y
sudo ss -tlnp | grep -E ':80|:443' # should be empty now
2.4 Optional but recommended: firewall + auto-updates #
sudo apt install -y unattended-upgrades fail2ban ufw
sudo dpkg-reconfigure --priority=low unattended-upgrades
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow http
sudo ufw allow https
sudo ufw enable
sudo ufw status verbose
Phase 3 — DNS + TLS cert #
3.1 DNS A-record #
Point your chosen hostname at the server’s public IP. For example,
in your DNS provider’s UI:
A bizuno.your-domain.com → 71.78.x.x
Verify from your local machine (not from the server itself — ping
on the server resolves via 127.0.1.1 due to Debian’s default
/etc/hosts):
dig +short bizuno.your-domain.com
# expected: your server's public IP
3.2 Pre-DNS testing via /etc/hosts (optional but useful) #
If DNS hasn’t propagated yet, OR you want to test from inside your
LAN before flipping the public record, add a temporary entry to
your local machine’s hosts file:
# On macOS or Linux client
sudo nano /etc/hosts
# Add:
# 192.168.x.x bizuno.your-domain.com (LAN IP if testing from LAN)
# 71.78.x.x bizuno.your-domain.com (WAN IP if testing from outside)
# Flush DNS cache (macOS):
sudo killall -HUP mDNSResponder
# Verify:
ping -c 1 bizuno.your-domain.com # should hit the IP you set
Remove the override once real DNS is in place.
3.3 Upload the TLS cert #
Two files on the server: the certificate (fullchain) and the private
key. Caddy reads both at startup.
# On the server, pre-create the directory with locked-down perms:
sudo mkdir -p /etc/ssl/docker
sudo chmod 700 /etc/ssl/docker
sudo chown root:root /etc/ssl/docker
# From your local machine, upload the cert + key:
scp wildcard.your-domain.com.pem wildcard.your-domain.com.key \
yo******@**ur.server:/tmp/
# Back on the server:
sudo mv /tmp/wildcard.your-domain.com.* /etc/ssl/docker/
sudo chown root:root /etc/ssl/docker/wildcard.your-domain.com.*
sudo chmod 600 /etc/ssl/docker/wildcard.your-domain.com.*
ls -la /etc/ssl/docker/
The cert file should be the fullchain (your cert + intermediate
CA certs concatenated). If your CA gave you separate cert.pem and
chain.pem files, concatenate them: cat cert.pem chain.pem > fullchain.pem.
Phase 4 — Build the Bizuno image #
The Bizuno container image is built from the repo. There’s no
prebuilt image to pull yet (the CI workflow pushes on v* tag pushes
which haven’t happened for the current main), so we clone + build
locally.
cd ~
git clone https://github.com/phreesoft/bizuno.git bizuno-src
cd bizuno-src
bash build/docker/build.sh
The build takes ~5 minutes on a first run. It produces a multi-tagged
image:
docker images | grep phreesoft/bizuno
# ghcr.io/phreesoft/bizuno:7.x.x <hash> ... 1.06GB
# ghcr.io/phreesoft/bizuno:latest <hash> ... 1.06GB
Both tags point at the same image. The compose stack references
:latest by default but you can pin to the specific version via
BIZUNO_VERSION in .env.
Phase 5 — Configure the compose stack #
The repo ships a ready-to-use stack template at
build/docker/production/.
Copy it to your home directory:
mkdir -p ~/bizuno
cp ~/bizuno-src/build/docker/production/docker-compose.yml ~/bizuno/
cp ~/bizuno-src/build/docker/production/Caddyfile ~/bizuno/
cp ~/bizuno-src/build/docker/production/.env.example ~/bizuno/.env
chmod 600 ~/bizuno/.env
ls -la ~/bizuno/
# expect: .env Caddyfile docker-compose.yml
5.1 Generate secrets #
The .env file needs strong values for every secret. Generate them
all at once:
cat <<EOF > ~/bizuno/.env
# Image
BIZUNO_VERSION=latest
# Hostname Bizuno serves at
BIZUNO_HOSTNAME=bizuno.your-domain.com
# TLS cert paths (must match where you put them in Phase 3.3)
CADDY_CERT_FILE=/etc/ssl/docker/wildcard.your-domain.com.pem
CADDY_KEY_FILE=/etc/ssl/docker/wildcard.your-domain.com.key
# Bizuno secrets — generated fresh:
BIZUNO_KEY=$(openssl rand -base64 16 | tr -dc 'A-Za-z0-9' | head -c16)
BIZUNO_BIZID=$(openssl rand -base64 6 | tr -dc 'A-Za-z0-9' | head -c6)
# Database
BIZUNO_DB_NAME=bizuno
BIZUNO_DB_USER=bizuno
BIZUNO_DB_PASS=$(openssl rand -base64 24)
MARIADB_ROOT_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 ~/bizuno/.env
cat ~/bizuno/.env # review the values — make sure nothing's blank
Critical: copy
BIZUNO_KEYsomewhere safe right now. It
encrypts every piece of stored PII in Bizuno. If you ever lose the
Docker volumes, you need this key to decrypt anything you re-import.
A password manager entry is the right place.
5.2 Review the compose file (optional) #
The default docker-compose.yml is set up for the typical case (TLS
via mounted wildcard, MariaDB internal-only, Caddy on host ports
80/443). If you need a managed external database, want to publish
the MariaDB port for a GUI client, or otherwise change the topology,
edit docker-compose.yml now before bringing up. Inline comments
explain each section.
Phase 6 — Bring up the stack #
cd ~/bizuno
docker compose up -d # boots all three containers in the background
docker compose ps # check status
Wait ~30–60 seconds for everything to become healthy. Expected:
NAME IMAGE STATUS
bizuno ghcr.io/phreesoft/bizuno:latest Up 45s (healthy)
bizuno-caddy caddy:2-alpine Up 45s
bizuno-mariadb mariadb:11 Up 55s (healthy)
If any container shows restarting or unhealthy, check its logs:
docker compose logs --tail=50 bizuno
docker compose logs --tail=50 mariadb
docker compose logs --tail=50 caddy
Phase 7 — Verify + run the Bizuno installer #
7.1 HTTPS probe #
From your local machine (NOT from inside the server):
curl -I https://bizuno.your-domain.com/
Expected response:
HTTP/2 200
content-type: text/html; charset=UTF-8
server: Apache/2.4.67 (Debian)
via: 1.1 Caddy
set-cookie: PHPSESSID=...
strict-transport-security: max-age=31536000; includeSubDomains
x-powered-by: PHP/8.2.x
Key signals:
| Header | What it confirms |
|---|---|
HTTP/2 200 |
Cert valid, TLS handshake completed, no redirect loops |
via: 1.1 Caddy |
Caddy is proxying (not just answering directly) |
server: Apache/... |
Request reached the Bizuno container, not just Caddy |
set-cookie: PHPSESSID=... |
PHP ran inside the container; Bizuno responded |
7.2 Browser walkthrough #
Open https://bizuno.your-domain.com/ in a browser. Bizuno’s
first-run installer wizard appears:
- Welcome / DB check — credentials from
.envare pre-populated,
you’ll see a “connected” indicator on first load. - Business info — name, currency, timezone, fiscal year.
- Admin user — email + password (your first login).
- Chart of accounts type — retail / services / single-entity / etc.
- Click Install — schema gets created in MariaDB, ~10 seconds.
- Page reloads to the Bizuno dashboard, logged in as the admin
you just created.
You’re now running production Bizuno.
Common gotchas #
Real symptoms encountered during the first run of this walkthrough,
with fixes:
| Symptom | Cause | Fix |
|---|---|---|
psww05srv may not run sudo |
User wasn’t added to sudo group at install time |
Log in as root via su - or direct SSH; usermod -aG sudo youruser |
usermod: command not found running as the unprivileged user |
usermod is in /usr/sbin/ which isn’t on the regular-user $PATH |
Must be run from a root shell, not under sudo from a regular shell |
docker compose pull fails on the bizuno image with “denied” |
The ghcr.io image hasn’t been pushed for this version yet | Build locally (Phase 4) instead of pulling |
no configuration file provided: not found |
docker-compose.yml file has a typo — common: docker-composer.yaml (PHP-flavored muscle memory) |
mv docker-composer.yaml docker-compose.yml |
HTTP/2 301 infinite redirect loop |
Apache inside container sees HTTPS=off from Caddy, redirects to https, loops |
Ensure your build includes the .htaccess + portalCFG-sample.php X-Forwarded-Proto fixes (commit 37b752b or later). Rebuild image: bash build/docker/build.sh && docker compose up -d --force-recreate bizuno |
Could not resolve host from the server itself |
/etc/hosts on Debian has 127.0.1.1 <hostname> as default; ping from the server hits localhost |
Test from your local machine, not from the server. The chain still works internally. |
Caddy fails with tls: open /etc/ssl/docker/...: no such file or directory |
Cert files at wrong path | Confirm paths in .env match where you placed the cert files. ls -la /etc/ssl/docker/ |
Cert chain validation error from curl |
The .pem file is just the cert, not the fullchain |
Concatenate: cat cert.pem chain.pem > fullchain.pem and re-upload |
Day-2: backups + updates #
Backup the three critical volumes #
docker volume ls | grep bizuno_
# bizuno_bizuno-data ← uploads, attachments, biz-instance-key.php
# bizuno_bizuno-config ← portalCFG.php (contains BIZUNO_KEY)
# bizuno_mariadb-data ← every Bizuno table
# bizuno_caddy-data ← TLS state (regenerable)
# bizuno_caddy-config ← Caddy runtime (regenerable)
The first three are critical. A simple backup script:
#!/usr/bin/env bash
# /home/youruser/bizuno/backup.sh
set -euo pipefail
cd /home/$USER/bizuno
TS=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR=/var/backups/bizuno
sudo mkdir -p "$BACKUP_DIR"
for vol in mariadb-data bizuno-data bizuno-config; do
docker run --rm \
-v bizuno_${vol}:/data:ro \
-v "$BACKUP_DIR":/backup \
alpine tar -czf /backup/${vol}-${TS}.tar.gz -C / data
done
echo "Wrote backups to $BACKUP_DIR with timestamp $TS"
Schedule via cron nightly, sync off-host weekly.
Update Bizuno when a new version lands #
cd ~/bizuno-src
git pull
bash build/docker/build.sh # rebuild image with new code
cd ~/bizuno
docker compose up -d --force-recreate bizuno # restart only bizuno; mariadb + caddy untouched
docker compose logs -f bizuno # tail until you see Apache start cleanly
If the update changes the database schema, Bizuno runs migrations
automatically on first request after restart. Watch the logs for the
migration messages.
Restart after .env or Caddyfile changes #
cd ~/bizuno
docker compose up -d # picks up env / file changes
Stop / wipe everything (destructive) #
docker compose down # stop, preserves volumes
docker compose down -v # ALSO deletes the volumes — total wipe
Alternative: Let’s Encrypt instead of a wildcard cert #
If you’d rather have Caddy provision + renew certs automatically via
ACME, remove the tls directive from the Caddyfile entirely:
{$BIZUNO_HOSTNAME} {
# tls {$CADDY_CERT_FILE} {$CADDY_KEY_FILE} ← delete or comment this line
reverse_proxy bizuno:80 {
header_up X-Forwarded-Proto https
...
}
...
}
Caddy will use its built-in ACME client on first start. Requires:
- The hostname’s A-record already pointing at this server (Let’s
Encrypt validates by HTTP-01 challenge on port 80, which this
stack already exposes). - Outbound HTTPS allowed (to reach
acme-v02.api.letsencrypt.org).
You can then drop CADDY_CERT_FILE / CADDY_KEY_FILE from .env
and the cert mount from docker-compose.yml.
See also #
build/docker/production/README.md— reference docs for the stack files themselvesbuild/docker/README.md— image-level documentation, env var referencebuild/docker/Dockerfile— image definitionbuild/docker/entrypoint.sh— howportalCFG.phpis generated from env vars on first boot- Three Ways to Run It — the high-level deployment-options overview
- Administration → Backup and Restore — Bizuno-level backup mechanics
