This is the fastest way to stand up a complete TicketsCAD NewUI instance — application and database — on any machine with Docker. One command builds the app image, starts MariaDB, installs the schema, creates an admin account, and serves the dispatch console.
Who this is for: anyone evaluating TicketsCAD or running it on a single host (a home lab, a squad's server, a cloud VM). For a hardened, internet-facing deployment, also read the "Production notes" section at the end.
- Docker Engine 24+ and the Docker Compose plugin (
docker compose, not the olderdocker-compose). On Debian/Ubuntu:curl -fsSL https://get.docker.com | sudo sh sudo usermod -aG docker "$USER" # log out/in so you can run docker without sudo
- ~2 GB free disk for the images, plus room for your data.
- git to clone the repository.
That's it — you do not need PHP, Composer, or MariaDB installed on the host. Everything runs inside containers.
# 1. Get the code
git clone https://github.com/openises/TicketsCAD.git ticketscad
cd ticketscad
# 2. Create your environment file and set passwords
cp .env.example .env
nano .env # change NEWUI_DB_PASS and DB_ROOT_PASSWORD at minimum
# 3. Build and start (app + database)
docker compose up -d --build
# 4. Watch the first-run install finish (schema + admin user)
docker compose logs -f appOn first start the app container waits for the database, installs the full schema, applies every migration, and creates the admin account. When you see:
[entrypoint] Install complete. Sign in at http://localhost:8081
[entrypoint] Starting Apache...
open http://localhost:8081 in your browser.
- If you set
ADMIN_PASSWORDin.env, use that. - If you left it blank, a random one was generated — find it in the log:
You'll be prompted to choose your own password on first login.
docker compose logs app | grep -iE "password|temp"
All settings are environment variables in .env (read automatically by
docker compose):
| Variable | Default | Purpose |
|---|---|---|
NEWUI_DB_NAME |
newui |
Database name (created automatically). |
NEWUI_DB_USER |
newui |
Database user. |
NEWUI_DB_PASS |
newui |
Database password — change this. |
DB_ROOT_PASSWORD |
change-me-root |
MariaDB root password — change this. |
NEWUI_PORT |
8081 |
Host port the app is published on (http://localhost:<port>). |
NEWUI_BASE_URL |
http://localhost:8081 |
Public URL used to build links/cookies. Set to your https:// host in production. |
ADMIN_USER |
admin |
First-run admin username. |
ADMIN_EMAIL |
admin@example.invalid |
First-run admin email. |
ADMIN_PASSWORD |
(blank → auto-gen) | First-run admin password. Blank = generated + printed to the log. |
NEWUI_SEED_DEMO |
false |
true seeds demo incident types + sample units/facilities. |
After editing .env, apply changes with docker compose up -d (recreates the
containers). Note that ADMIN_* and NEWUI_SEED_DEMO only take effect on the
first install (an empty database) — they don't rotate an existing admin's
password.
Your data lives in named Docker volumes, so it survives docker compose down
and image rebuilds:
| Volume | Mounted at | Holds |
|---|---|---|
db_data |
/var/lib/mysql |
The entire database. |
app_uploads |
/var/www/html/uploads |
Attachments, photos, uploaded files. |
app_cache |
/var/www/html/cache |
Weather-overlay tiles and NWS lookups. |
app_tile_cache |
/var/www/tile-cache |
Basemap tiles fetched by api/tile-proxy.php. Outside /var/www/html — this cache records which map areas the install has viewed, which inside the webroot would be readable without logging in. Regenerable, but keep it on a volume: a rebuild that empties it makes the install re-fetch every tile at once, which is the load spike tile providers ask us not to cause. |
app_geocode_cache |
/var/www/geocode-cache |
Address-lookup cache (inc/geocode.php). Outside /var/www/html, same reasoning as app_tile_cache; regenerable but worth keeping on a volume so a rebuild doesn't re-hit the geocoding provider for every cached address at once. |
app_backups |
/var/www/backups |
Archives written by tools/backup_run.php and Settings → Backup / Maintenance. Outside /var/www/html — that is the Apache DocumentRoot, and an archive inside it was downloadable by anyone who guessed the filename (v4.2.3). |
app_keys |
/var/www/keys |
2FA + RSA field-encryption keys (kept out of the webroot), and — in its tts/ subdirectory — the text-to-speech API keys saved under Settings → Voice & Speech. |
app_zello_audio |
/var/www/zello-audio |
Zello voice-message recordings (GHSA-x9x6-w4fg-pmcc). Outside /var/www/html, mounted on BOTH the app service and the zello-proxy service (the proxy is what actually writes recordings). Irreplaceable, not regenerable — this is precious data, not a cache. |
Anything NOT on this list lives in the container's writable layer and is
destroyed by docker compose up -d --build, which replaces the container.
That is why app_backups exists: without it, a backup taken with
docker compose exec app php tools/backup_run.php --force was deleted by the
very next command of the documented upgrade — in the one scenario the backup
exists for.
Back up
db_dataandapp_keystogether. The keys decrypt data stored in the database; a database restored without its matching keys cannot read encrypted fields (2FA secrets, encrypted form fields).
Adding the volume does not import what was already in the old container — Docker only seeds a new named volume from the image, never from a running container's writable layer. If you have backups inside the container, rescue them before the first rebuild with the new compose file:
# 1. With the OLD container still running:
docker compose cp app:/var/www/html/backups ./backups-rescued # old path
# 2. Now pull the new code + rebuild (this is what would have destroyed them):
git pull && docker compose up -d --build
# 3. Put them back on the new volume:
docker compose cp ./backups-rescued/. app:/var/www/backups
docker compose exec app chown -R www-data:www-data /var/www/backupsIf docker compose cp reports the path does not exist, you had no on-container
backups and there is nothing to migrate — just rebuild.
In v4.2.27 and earlier, the API keys you saved under Settings → Voice & Speech
(Deepgram, an OpenAI-compatible server) were written to
/var/www/html/keys/tts — inside the container's writable layer, so the update
command above destroyed them (the engine then quietly fell back to Piper). They
now live in /var/www/keys/tts, on the app_keys volume. If you have saved such
a key and are still on the old container, rescue it before the first rebuild:
docker compose cp app:/var/www/html/keys/tts ./tts-keys-rescued # old path
git pull && docker compose up -d --build
docker compose cp ./tts-keys-rescued/. app:/var/www/keys/tts
docker compose exec app chown -R www-data:www-data /var/www/keys/ttsIf docker compose cp reports the path does not exist you never saved a key —
nothing to do. If the container was already rebuilt, just paste the key again
under Voice & Speech; it is stored in the right place now.
A volume survives rebuilds, but it still lives on this host. Copy the archive
somewhere else (the 3-2-1 rule — see docs/BACKUP-RECOVERY-RUNBOOK.md):
docker compose cp app:/var/www/backups ./ticketscad-backups…or use Settings → Backup / Maintenance → Download Full Backup, which streams the archive straight to the browser's machine.
Back up the database at any time:
docker compose exec db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE"' > backup.sqlSchema migrations run automatically on container start — so an upgrade is just "get the newer code, rebuild the image, restart." Back up your database first (a migration changes the schema in place):
# 0. If your compose file predates the app_backups volume, do the one-time
# rescue in §4 FIRST — step 3 below destroys anything not on a volume.
# 1. Back up first (see §4). This writes to the host, so it is safe either way.
docker compose exec db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE"' > backup-$(date +%F).sql
# (Or use TicketsCAD's own backup, which now lands on the app_backups volume
# and survives the rebuild: docker compose exec app php tools/backup_run.php --force)
# 2. Get the new version. For production, pin to a released tag (a known-good
# version) rather than bleeding-edge main:
git fetch --tags
git checkout v4.0.1 # newest tag: git tag -l | sort -V | tail -1
# (or, to track the latest development: git pull)
# 3. Rebuild + restart. The --build is REQUIRED — a plain `git pull` alone does
# NOT update the running container (it keeps the old built image).
docker compose up -d --buildHow migrations are handled: the container entrypoint runs tools/install_fresh.php
on every start. On an empty database it installs the full schema; on an existing
one it applies only the pending migrations. Every migration is tracked in the
_migrations table, so already-applied ones are skipped and nothing is dropped.
It's idempotent and safe to re-run on every restart.
Confirm it worked: migrations are non-fatal by design (a failure never blocks the app from starting), so after any upgrade check the log:
docker compose logs app | grep -iE 'migration|install|error'If an upgrade misbehaves, roll back to your backup and the previous tag:
docker compose exec -T db sh -c 'exec mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE"' < backup-YYYY-MM-DD.sql
git checkout v4.0.0 && docker compose up -d --builddocker compose ps # container status
docker compose logs -f app # follow app logs
docker compose logs -f db # follow database logs
docker compose restart app # restart just the app
docker compose down # stop (data volumes preserved)
docker compose down -v # stop AND DELETE all data volumes (destructive!)
docker compose exec app bash # shell inside the app containerRun a health check from inside the container:
docker compose exec app php tools/health_check.php # if present in your build…or open the System Status (or Diagnostics) health page in the web UI.
By default the entrypoint generates config.php from the environment variables
above. If you'd rather manage the full config yourself (extra settings, custom
SMTP, etc.), create a config.php from config.example.php and mount it — the
entrypoint will detect it and leave it alone. Uncomment this line in
docker-compose.yml:
volumes:
- ./config.php:/var/www/html/config.php:roThe quick-start is plaintext HTTP on port 8081 — perfect for evaluation, not for an internet-facing deployment. For production:
- Put it behind TLS. Run a reverse proxy (Caddy, nginx, or Traefik) that
terminates HTTPS and forwards to the app container, and set
NEWUI_BASE_URLto yourhttps://hostname. TicketsCAD enforces secure cookies when the base URL ishttps://. - Use strong, unique passwords for
NEWUI_DB_PASSandDB_ROOT_PASSWORD. - Don't publish the database port. The compose file keeps MariaDB on the internal Docker network only — leave it that way.
- Back up
db_data+app_keyson a schedule (see §4). - Voice features are opt-in via a compose profile — see §8a. The
hardware radio bridges (native DMR/AMBE to BrandMeister, Meshtastic) need a
physical radio/serial device and run on the host, not in this stack — see their
docs under
docs/(RADIO-DMR-INSTALL.md, Meshtastic guide).
The browser radio widget's push-to-talk needs a small WebSocket relay per network (Zello, DMR). These ship as an optional compose profile that reuses the app image (nothing extra to build) and are off by default:
docker compose --profile voice up -d # app + db + zello-proxy + dmr-proxyThat's it — the app image is already wired to reverse-proxy the browser's
wss://<host>/zello-ws and wss://<host>/dmr-ws connections to the two relay
containers (zello-proxy on 8090, dmr-proxy on 8092). You do not publish
those ports; they stay on the internal Docker network.
Then, in the app, enable and configure the channels:
- Log in as an admin → Settings → Communications & Integrations (Zello and/or DMR).
- Enter your Zello credentials (Work network + username/password, or Consumer token) and/or your DMR bridge channels. Leave the proxy ports at their defaults (8090 / 8092) so the built-in WebSocket routing matches.
- Open the radio widget on the dashboard — it connects through the app to the relay. Green status = connected.
Notes:
- Behind your own TLS reverse proxy (Caddy/nginx/Traefik, §8): make sure it
WebSocket-upgrades
/zello-wsand/dmr-wsin addition to normal traffic, or push-to-talk won't connect. - Turn it off by bringing the stack up without the profile again
(
docker compose up -d) — the relays stop; the core CAD is unaffected. - The relays read their settings from the same database; they carry no state of their own.
- This profile covers Zello and the DMR relay. The native DMR/AMBE bridge to BrandMeister and Meshtastic need real radio hardware on the host and are deployed separately (see their docs).
| Symptom | Likely cause / fix |
|---|---|
dependency failed to start: container ticketscad_db is unhealthy |
The database container exited before it became healthy — the app then refuses to start. Get the real reason with docker compose logs db. See "Database won't start on a small host" below — on Raspberry Pi and low-RAM VMs this is almost always out-of-memory or a 32-bit OS. |
| App log stuck on "Waiting for database" | DB still initializing on first boot — wait; docker compose logs db for errors. |
| Container is up but no TicketsCAD in the browser | You're at the wrong address. The app is at http://localhost:8081 (the port after the colon) — not :8080, and not a folder path like /ticketscad or /ticketscad_newui (those are the container names, not URLs). A plain http://localhost showing "Apache2 Ubuntu Default Page" / "It works!" is your host's own web server, not TicketsCAD — ignore it and use :8081. New to this? See GETTING-STARTED-FOR-BEGINNERS.md. |
| "database not reachable after 120s" | Wrong NEWUI_DB_PASS vs. what the DB was first created with. If you changed it after first boot, the db_data volume still has the old one — docker compose down -v to reset (destroys data) or fix the password to match. |
| Login page loads but assets are 404 | .htaccess disabled — the image enables AllowOverride All; if you customized Apache, restore it. |
| Forgot the generated admin password | docker compose exec app php tools/create_admin.php --username=admin --email=you@example.com prints a fresh temp password (first login forces a change). |
| Want a clean slate | docker compose down -v && docker compose up -d --build (deletes ALL data). |
docker compose pull fails with pull access denied for ticketscad-newui (or repository does not exist) |
Don't run docker compose pull for this project — the app image (ticketscad-newui:local) is built locally, on your own machine, from this repo's Dockerfile; it is never published to Docker Hub or any registry, so there's nothing there to pull. This is expected, not a sign anything is broken. To get the latest version, use git pull then docker compose up -d --build (see §5) — that's the whole update, no separate pull step. If a plain docker compose up -d (no --build, no prior pull) won't bring your containers back up either, see §5 and the troubleshooting rows above for the real error from docker compose logs. |
If docker compose up ends with dependency failed to start: container ticketscad_db is unhealthy, the MariaDB container exited before it finished
starting. Always look at the real reason first:
docker compose logs dbOn a Raspberry Pi or a small VM, the cause is almost always one of these:
-
Out of memory. MariaDB gets OOM-killed — often while the app image is still building in parallel (that build is CPU/RAM heavy and can take 15+ minutes on a Pi). The db log ends abruptly or the host shows an OOM message.
- Check free memory:
free -h. - Give the Pi swap if it has little RAM (a 2 GB swapfile is plenty), or build
the image and start the database in two steps so they don't compete:
docker compose buildfirst, thendocker compose up -d. - 1 GB Pis are tight; 2 GB or more is comfortable.
- Check free memory:
-
32-bit operating system. MariaDB 11 only ships 64-bit images (
arm64/amd64). On a Pi, run the 64-bit Raspberry Pi OS —uname -mshould printaarch64, notarmv7l/armhf. -
A half-initialized data volume from an earlier failed attempt. Start completely clean (this deletes any data, which is fine on a first install):
docker compose down -vthendocker compose up -d.
Once docker compose logs db shows mariadbd: ready for connections, the app
container will start on its own.
Questions or problems? Open an issue at https://github.com/openises/TicketsCAD/issues.