Deployment
VM and bare-metal deployment
This page shows you how to run a Wheels app on a traditional Linux VM — no containers, no orchestrator, just Lucee under systemd with nginx in front. You’ll install Lucee 7, lay out the app under /opt/myapp, write a systemd unit, terminate TLS in nginx, and run a zero-downtime release swap with rsync and a symlink.
You’ll learn:
- How to install Lucee 7 and Java 21 on a Linux VM and run the app as a service
- How to lay out releases so a new deploy is a single
ln -sfnswap away from live - How to terminate TLS in nginx, reverse-proxy to Lucee on loopback, and rotate logs
Why deploy to a VM
Section titled “Why deploy to a VM”Docker is the default for most new Wheels deploys, but plenty of teams still ship onto VMs or bare metal. Pick this path when:
- Regulatory or audit constraints require a named host, a disk-encrypted volume, and no shared container runtime.
- Your ops team runs everything on VMs already — monitoring agents, backup hooks, configuration management. A container is a new pane of glass they don’t want.
- There’s no Docker host available — a hosted VPS, a colo box, an AS/400 shop’s first Linux VM.
- Lift-and-shift from a legacy CF host — the old app ran as a service on a box; the new one can too, with minimal change.
The trade-off is manual work at setup and upgrade time. You install the JRE, you install Lucee, you write the systemd unit. In exchange you get a long-lived host you can SSH into, inspect, and reason about exactly the way you always have.
Prerequisites
Section titled “Prerequisites”- A Linux VM with root access. Ubuntu 22.04 LTS or Rocky 10 are good defaults.
- A domain name with an
Arecord pointing at the VM’s public IP. - Ports 80 and 443 open inbound; everything else closed.
- Java 21. Lucee 7 runs on Java 21 — confirmed by the Wheels test image, which pins
lucee@7.0.1+100(tools/docker/lucee7/Dockerfile).
Install the JDK from your distro’s package manager:
# Ubuntu / Debiansudo apt-get updatesudo apt-get install -y openjdk-21-jre-headless rsync nginx logrotate
# Rocky / RHELsudo dnf install -y java-21-openjdk-headless rsync nginx logrotateVerify:
java -version# openjdk version "21.0.x" ...Install Lucee 7
Section titled “Install Lucee 7”Lucee 7 is the canonical engine target for Wheels 4. Two install options work equally well for a VM deployment.
Option A — official installer. Download the Linux installer from the Lucee downloads page and run it. The installer lays Lucee down under /opt/lucee (or a path you pick), installs a Tomcat servlet container, and generates an init/systemd service. This is the shortest path if you want Lucee’s built-in HTTP listener and admin console without extra wiring.
Option B — tarball drop-in. Download the Lucee tarball (JAR + lib/), extract to /opt/lucee, and run it directly with java -jar. This gives you fewer moving parts — no bundled Tomcat, no installer-managed service — at the cost of writing the systemd unit yourself. For a single-purpose Wheels VM, this is usually what you want.
Follow the Lucee install docs end-to-end rather than re-typing them here — they’re the authoritative source and they change with each release. See Lucee installation guides.
After install, confirm Lucee is listening on a local port (8888 is a common default) and that a lucee.sh (or equivalent) start script exists in /opt/lucee/bin.
App directory layout
Section titled “App directory layout”Lay the app out under /opt/myapp so releases are immutable, shared state lives outside any release, and current is a symlink you swap atomically.
/opt/myapp/├── current -> releases/20260421T143022Z (symlink)├── releases/│ ├── 20260418T081500Z/│ ├── 20260420T101200Z/│ └── 20260421T143022Z/└── shared/ ├── .env ├── logs/ ├── uploads/ └── sessions/releases/<timestamp>— one directory per deploy. Contains the full app tree (app/,config/,public/,vendor/, etc.). Immutable once rsynced.current— a symlink pointing at the active release. Lucee and nginx both reference this path; swapping the symlink swaps the live app.shared/— everything that must survive across releases. Logs, user uploads, persisted sessions on disk, and the.envfile containing secrets. Each new release symlinks these paths back into itself before going live.
Create the layout once:
sudo mkdir -p /opt/myapp/releases /opt/myapp/shared/{logs,uploads,sessions}sudo chown -R lucee:lucee /opt/myappPut the production .env in /opt/myapp/shared/.env and lock its permissions:
sudo install -m 600 -o lucee -g lucee /dev/stdin /opt/myapp/shared/.env <<'EOF'WHEELS_ENV=productionDB_PASSWORD=...WHEELS_RELOAD_PASSWORD=...EOFWheels reads these at boot via env("VAR_NAME"). See Environments and Configuration for where that lookup happens.
systemd unit
Section titled “systemd unit”Run Lucee as a systemd service so it starts on boot, restarts on crash, and logs through journald. Create /etc/systemd/system/myapp.service:
[Unit]Description=MyApp Wheels application (Lucee 7)After=network.target
[Service]Type=simpleUser=luceeGroup=luceeWorkingDirectory=/opt/myapp/currentEnvironmentFile=/opt/myapp/shared/.envExecStart=/opt/lucee/bin/lucee.sh runExecStop=/opt/lucee/bin/lucee.sh stopRestart=on-failureRestartSec=5LimitNOFILE=65536StandardOutput=append:/opt/myapp/shared/logs/lucee.out.logStandardError=append:/opt/myapp/shared/logs/lucee.err.log
[Install]WantedBy=multi-user.targetKey pieces:
EnvironmentFile=loads the.envinto the service’s environment.WHEELS_ENV=productionis what flips Wheels into production mode.WorkingDirectory=/opt/myapp/currentis the symlink — systemd re-resolves it on every restart, so a deploy swap picks up the new release without editing the unit.Restart=on-failurewithRestartSec=5gives the process five seconds between restarts. If your init script does a graceful shutdown, also setTimeoutStopSec=30.User=luceeruns the process as an unprivileged account. Don’t run Lucee as root. Create the user up front:sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin lucee.
Enable and start:
sudo systemctl daemon-reloadsudo systemctl enable --now myappsudo systemctl status myappLucee should now be listening on 127.0.0.1:8888 (or whatever port your install uses). Curl it locally to confirm:
curl -I http://127.0.0.1:8888/nginx reverse proxy
Section titled “nginx reverse proxy”nginx terminates TLS, serves static assets out of public/, and proxies everything else to Lucee on loopback. Create /etc/nginx/sites-available/myapp.conf:
upstream myapp_lucee { server 127.0.0.1:8888; keepalive 32;}
server { listen 80; server_name myapp.example.com; return 301 https://$host$request_uri;}
server { listen 443 ssl http2; server_name myapp.example.com;
ssl_certificate /etc/letsencrypt/live/myapp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3;
root /opt/myapp/current/public;
# Serve static files directly; fall back to Lucee for dynamic requests. location / { try_files $uri @lucee; }
location @lucee { proxy_pass http://myapp_lucee; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_read_timeout 60s; }
access_log /opt/myapp/shared/logs/nginx.access.log; error_log /opt/myapp/shared/logs/nginx.error.log;}Enable the site and reload:
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/sudo nginx -tsudo systemctl reload nginxGet a TLS cert with Let’s Encrypt:
sudo certbot --nginx -d myapp.example.comCertbot edits the nginx config to wire the cert paths and installs a renewal timer. sudo certbot renew --dry-run verifies renewal works before the cert actually expires.
Firewall
Section titled “Firewall”Only 443 (and 80 for the Let’s Encrypt redirect) should be open to the internet. Lucee stays on loopback.
# Ubuntu / Debian (ufw)sudo ufw allow 22/tcpsudo ufw allow 80/tcpsudo ufw allow 443/tcpsudo ufw enable
# Rocky / RHEL (firewalld)sudo firewall-cmd --permanent --add-service=httpsudo firewall-cmd --permanent --add-service=httpssudo firewall-cmd --reloadLucee’s own HTTP listener binds to 127.0.0.1 by convention — confirm in your Lucee/CommandBox server config. A Lucee listener on 0.0.0.0:8888 behind an open firewall exposes the admin panel to the world.
Zero-downtime deploy
Section titled “Zero-downtime deploy”The release swap is three steps on the VM: rsync the new code in, run migrations, flip the symlink. nginx gets a reload so it re-resolves the symlink; systemd gets nothing — Lucee keeps serving out of the old release until the next restart.
From your build machine or CI runner:
-
Build a release locally. Commit,
git archive, or checkout into a clean working directory. Make surevendor/contains any production dependencies your app needs. -
rsync to a new timestamped directory.
rsync release to VM RELEASE=$(date -u +%Y%m%dT%H%M%SZ)rsync -az --delete \--exclude='.env' \--exclude='tests/' \./ lucee@myapp.example.com:/opt/myapp/releases/$RELEASE/ -
Wire the shared paths in. On the VM, replace per-release log/upload/session directories and
.envwith symlinks intoshared/.Wire shared paths into the release ssh lucee@myapp.example.com bash -s <<EOFset -eucd /opt/myapp/releases/$RELEASEln -sfn /opt/myapp/shared/.env .envrm -rf app/logs && ln -sfn /opt/myapp/shared/logs app/logsrm -rf public/uploads && ln -sfn /opt/myapp/shared/uploads public/uploadsEOF -
Run migrations against the new release. Point the CLI at the new release so it reads the new migration files.
Run database migrations on the new release ssh lucee@myapp.example.com "cd /opt/myapp/releases/$RELEASE && wheels migrate latest"If a migration fails, stop here. The old
currentsymlink is still live; nothing changed for users. -
Flip the symlink.
Flip the symlink to the new release ssh lucee@myapp.example.com "ln -sfn /opt/myapp/releases/$RELEASE /opt/myapp/current"ln -sfnis the important flag combination:-ssymbolic,-fforce overwrite,-ntreat the existing target as a file rather than following it. This makes the swap atomic — nginx and systemd never see a partial state. -
Reload nginx and the app.
Reload nginx and restart Lucee ssh lucee@myapp.example.com "sudo systemctl reload nginx && sudo systemctl restart myapp"nginx reload is instant and graceful — in-flight requests finish against the old worker. Lucee’s restart is less graceful; for true zero-downtime you can run two Lucee instances on different loopback ports and rotate them behind nginx, but most apps can tolerate the one- or two-second blip.
-
Prune old releases.
Prune old releases (keep 5 most recent) ssh lucee@myapp.example.com "cd /opt/myapp/releases && ls -1t | tail -n +6 | xargs -r rm -rf"Keeps the five most recent releases. Keeping at least two means a one-command rollback.
Rolling back is the swap in reverse — ln -sfn /opt/myapp/releases/<previous> /opt/myapp/current and reload. If the failed deploy ran a migration, roll the migration back first (wheels migrate down) or accept that the older code is running against a newer schema.
Log rotation
Section titled “Log rotation”Lucee and nginx both write into /opt/myapp/shared/logs/. Rotate them weekly with logrotate. Create /etc/logrotate.d/myapp:
/opt/myapp/shared/logs/*.log { weekly rotate 8 compress delaycompress missingok notifempty copytruncate su lucee lucee}copytruncate avoids the dance of notifying Lucee to re-open its log files — logrotate copies the current log, then truncates the original. Good enough for most deployments. If you care about missing zero bytes at rotation time, switch to a rotation pattern that signals the app.
Upgrading Lucee in place
Section titled “Upgrading Lucee in place”Point releases within Lucee 7 are usually drop-in replacements. The rough shape:
- Back up
/opt/lucee— a plaintar -czf lucee-backup.tgz /opt/luceeis enough. - Stop the app:
sudo systemctl stop myapp. - Install the new Lucee version on top — either run the installer and point it at the same
/opt/lucee, or extract a new tarball. - Start the app:
sudo systemctl start myapp. Watch logs:journalctl -u myapp -f. - If anything misbehaves, stop the service, restore the backup tarball over
/opt/lucee, and start again.
Major-version upgrades (Lucee 6 → 7, 7 → 8) deserve a staging run first. See the Lucee upgrade docs for release-specific notes and the current compatibility matrix.