Skip to content

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 -sfn swap away from live
  • How to terminate TLS in nginx, reverse-proxy to Lucee on loopback, and rotate logs

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.

  • A Linux VM with root access. Ubuntu 22.04 LTS or Rocky 10 are good defaults.
  • A domain name with an A record 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:

Install Java 21 and dependencies
# Ubuntu / Debian
sudo apt-get update
sudo apt-get install -y openjdk-21-jre-headless rsync nginx logrotate
# Rocky / RHEL
sudo dnf install -y java-21-openjdk-headless rsync nginx logrotate

Verify:

Verify Java installation
java -version
# openjdk version "21.0.x" ...

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.

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.

App directory layout
/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 .env file containing secrets. Each new release symlinks these paths back into itself before going live.

Create the layout once:

Create directory layout
sudo mkdir -p /opt/myapp/releases /opt/myapp/shared/{logs,uploads,sessions}
sudo chown -R lucee:lucee /opt/myapp

Put the production .env in /opt/myapp/shared/.env and lock its permissions:

Write the .env file
sudo install -m 600 -o lucee -g lucee /dev/stdin /opt/myapp/shared/.env <<'EOF'
WHEELS_ENV=production
DB_PASSWORD=...
WHEELS_RELOAD_PASSWORD=...
EOF

Wheels reads these at boot via env("VAR_NAME"). See Environments and Configuration for where that lookup happens.

Run Lucee as a systemd service so it starts on boot, restarts on crash, and logs through journald. Create /etc/systemd/system/myapp.service:

/etc/systemd/system/myapp.service
[Unit]
Description=MyApp Wheels application (Lucee 7)
After=network.target
[Service]
Type=simple
User=lucee
Group=lucee
WorkingDirectory=/opt/myapp/current
EnvironmentFile=/opt/myapp/shared/.env
ExecStart=/opt/lucee/bin/lucee.sh run
ExecStop=/opt/lucee/bin/lucee.sh stop
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
StandardOutput=append:/opt/myapp/shared/logs/lucee.out.log
StandardError=append:/opt/myapp/shared/logs/lucee.err.log
[Install]
WantedBy=multi-user.target

Key pieces:

  • EnvironmentFile= loads the .env into the service’s environment. WHEELS_ENV=production is what flips Wheels into production mode.
  • WorkingDirectory=/opt/myapp/current is the symlink — systemd re-resolves it on every restart, so a deploy swap picks up the new release without editing the unit.
  • Restart=on-failure with RestartSec=5 gives the process five seconds between restarts. If your init script does a graceful shutdown, also set TimeoutStopSec=30.
  • User=lucee runs 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:

Enable and start the service
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

Lucee should now be listening on 127.0.0.1:8888 (or whatever port your install uses). Curl it locally to confirm:

Verify Lucee is listening
curl -I http://127.0.0.1:8888/

nginx terminates TLS, serves static assets out of public/, and proxies everything else to Lucee on loopback. Create /etc/nginx/sites-available/myapp.conf:

/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:

Enable nginx site and reload
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Get a TLS cert with Let’s Encrypt:

Obtain TLS certificate with Let's Encrypt
sudo certbot --nginx -d myapp.example.com

Certbot 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.

Only 443 (and 80 for the Let’s Encrypt redirect) should be open to the internet. Lucee stays on loopback.

Configure firewall rules
# Ubuntu / Debian (ufw)
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
# Rocky / RHEL (firewalld)
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

Lucee’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.

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:

  1. Build a release locally. Commit, git archive, or checkout into a clean working directory. Make sure vendor/ contains any production dependencies your app needs.

  2. 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/
  3. Wire the shared paths in. On the VM, replace per-release log/upload/session directories and .env with symlinks into shared/.

    Wire shared paths into the release
    ssh lucee@myapp.example.com bash -s <<EOF
    set -eu
    cd /opt/myapp/releases/$RELEASE
    ln -sfn /opt/myapp/shared/.env .env
    rm -rf app/logs && ln -sfn /opt/myapp/shared/logs app/logs
    rm -rf public/uploads && ln -sfn /opt/myapp/shared/uploads public/uploads
    EOF
  4. 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 current symlink is still live; nothing changed for users.

  5. 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 -sfn is the important flag combination: -s symbolic, -f force overwrite, -n treat the existing target as a file rather than following it. This makes the swap atomic — nginx and systemd never see a partial state.

  6. 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.

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

Lucee and nginx both write into /opt/myapp/shared/logs/. Rotate them weekly with logrotate. Create /etc/logrotate.d/myapp:

/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.

Point releases within Lucee 7 are usually drop-in replacements. The rough shape:

  1. Back up /opt/lucee — a plain tar -czf lucee-backup.tgz /opt/lucee is enough.
  2. Stop the app: sudo systemctl stop myapp.
  3. Install the new Lucee version on top — either run the installer and point it at the same /opt/lucee, or extract a new tarball.
  4. Start the app: sudo systemctl start myapp. Watch logs: journalctl -u myapp -f.
  5. 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.