Docker Compose Deployments on RHEL 9 with Rollbacks

What this deployment solves
This tutorial shows you how to run Docker Compose deployments on RHEL 9 with a production-friendly workflow: non-root access, SELinux-aware file placement, firewalld rules, health checks, controlled updates, and a rollback you can actually use when a release goes sideways. It fits customer portals, internal tools, landing pages, APIs, and small services that need predictable operations on a Hostperl VPS or a dedicated server from Hostperl VPS or Hostperl dedicated server hosting.
If you manage releases for a client site, this pattern also pairs well with the operational discipline in Git deployments on Docker Compose for safer releases and the restart guidance in Docker restart policies for safer app deployments.
Prerequisites and design choices
This guide is written for RHEL 9 and compatible clones such as Rocky Linux 9 and AlmaLinux 9. The same application layout works on Oracle Linux 9 as well, but package names and repositories may differ slightly depending on your subscription and enabled channels.
- A fresh RHEL 9 server with root access.
- A DNS name such as
server.example.compointed at the server. - A non-root sudo user named
deploy. - Docker Engine and Docker Compose plugin from the official Docker repository.
- One application directory:
/opt/myapp.
We will deploy a simple Nginx container behind host ports 80 and 443 so you can adapt the same structure to Node.js, Python, PHP, or any internal app that needs a reverse proxy.
1) Connect to the server and confirm the OS
On your local computer, open your first SSH session to the VPS.
ssh root@203.0.113.10203.0.113.10 is a reserved documentation address. Replace it with the real public IP assigned to your server. If your provider uses a default non-root account, use that account name with the same example IP.
On the VPS as root, identify the operating system before you install anything.
cat /etc/os-releaseYou should see RHEL, Rocky Linux, AlmaLinux, or a compatible identifier. That confirms the RHEL-family branch below is the right one.
2) Create the non-root admin user
Keep the root SSH session open. Open a second terminal later and test the new account before you remove any root access.
On the VPS as root, create the admin user, set a password, and add it to the wheel group.
useradd -m -s /bin/bash deploy
passwd deploy
usermod -aG wheel deployThis creates deploy, assigns a login shell, sets an initial password, and grants sudo access through wheel. If you use SSH keys only, you can lock the password after verification. For now, keep password login available until the new session is tested.
On the VPS as root, prepare the SSH directory and authorized keys file.
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keysAdjust the source key path if your public key lives elsewhere. The permissions matter; SSH will ignore overly open key files.
On your local computer, open a second terminal and test the new login.
ssh deploy@203.0.113.10After you connect, confirm sudo works.
sudo -v
sudo whoamiThe expected output is root. Do not continue until this works.
3) Update the server and install Docker
On the VPS as root, refresh packages and install the tools needed for the Docker repository.
dnf -y update
dnf -y install dnf-plugins-core yum-utils ca-certificates curlNext, add Docker’s official repository for RHEL-compatible systems.
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repoInstall Docker Engine, the CLI, the Compose plugin, and the container runtime.
dnf -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginEnable and start Docker, then verify the version.
systemctl enable --now docker
docker version
docker compose versionYou should see the server and client versions, plus a Compose plugin version. If the service does not start, check journalctl -u docker -b for errors before moving on.
4) Open the firewall safely
Open the new rules before you remove or tighten anything else. That avoids self-inflicted lockouts while you test the application.
On the VPS as root, allow SSH, HTTP, and HTTPS through firewalld.
systemctl enable --now firewalld
firewall-cmd --permanent --add-service=ssh
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
firewall-cmd --list-servicesYou should see ssh, http, and https in the list. If you expose a custom app port later, add it now with --add-port=8080/tcp or your chosen port before you close anything down.
5) Create the application directory structure
On the VPS as the non-root sudo user, create a clean directory tree for the Compose project.
sudo install -d -o deploy -g deploy /opt/myapp
cd /opt/myapp
pwdYou should be in /opt/myapp. Keep application files here so backups and restores stay predictable.
On the VPS as the non-root sudo user, create a simple web root and a sample page.
mkdir -p app
cat > app/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hostperl Docker Compose Demo</title>
</head>
<body>
<h1>Docker Compose on RHEL 9 is running</h1>
</body>
</html>
EOFThis gives you a simple smoke test before you connect the stack to a real app.
6) Write the Compose file and Nginx config
On the VPS as the non-root sudo user, create compose.yaml with a restart policy, a health check, and a bind mount for the content.
cat > compose.yaml <<'EOF'
services:
web:
image: nginx:1.27-alpine
container_name: myapp-web
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./app:/usr/share/nginx/html:ro,Z
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ || exit 1"]
interval: 10s
timeout: 3s
retries: 3
EOFThe :Z flag matters on SELinux systems. It relabels the bind mount so the container can read the files without disabling SELinux.
On the VPS as the non-root sudo user, start the stack.
docker compose up -d
docker compose psThe container should show Up or (healthy) after the health check passes. If it starts and then goes unhealthy, inspect the logs.
docker compose logs --tail=50 web7) Test from the server and from a client
On the VPS as the non-root sudo user, test the local HTTP response first.
curl -I http://127.0.0.1/
curl http://127.0.0.1/You should get a 200 OK and the sample HTML page.
On your local computer, test the public site.
curl -I http://203.0.113.10/
curl http://203.0.113.10/Replace 203.0.113.10 with your real server IP. If DNS is already live, use http://server.example.com/ instead.
8) Add a reverse proxy host entry for a real app
Most production releases eventually need a dedicated virtual host, especially when you place a Python, Node.js, or PHP container behind Nginx. Keep the container private on the Docker network and let Nginx listen on 80 and 443 only.
On the VPS as the non-root sudo user, create a working Nginx site file on the host if you are proxying to another container or local service later.
sudo tee /etc/nginx/conf.d/myapp.conf > /dev/null <<'EOF'
server {
listen 80;
server_name server.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
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;
}
}
EOFThis example assumes your app listens on 127.0.0.1:8000. Change the upstream address to match your service.
Before reloading Nginx, test the configuration.
sudo nginx -tYou want to see syntax is ok and test is successful. Only then reload the service.
sudo systemctl reload nginxIf Nginx is not installed yet, install it with dnf -y install nginx, then enable and start it with sudo systemctl enable --now nginx.
9) Handle SELinux correctly instead of disabling it
RHEL systems often fail because the network or bind-mount permissions are incomplete, not because Docker is broken. Keep SELinux enforcing unless you have a documented reason to change it.
On the VPS as root, confirm the current mode.
getenforceIf a container cannot read a mounted directory, check the SELinux context rather than turning SELinux off.
ls -ldZ /opt/myapp /opt/myapp/appIf the labels look wrong, reapply the correct context to the project directory.
restorecon -Rv /opt/myappFor bind mounts that still refuse access, the :Z mount suffix used earlier usually resolves the issue.
10) Create a safe update and rollback process
The most useful part of Compose in production is not the first launch. It is the repeatable release process.
On the VPS as the non-root sudo user, capture the current state before you change the image or the app content.
docker compose ps
cp compose.yaml compose.yaml.bak
cp app/index.html app/index.html.bakNow simulate an update by editing the page.
cat > app/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hostperl Docker Compose Demo v2</title>
</head>
<body>
<h1>Release v2 is live</h1>
</body>
</html>
EOF
docker compose up -d
docker compose psIf the new release is wrong, roll back with the saved file and restart the service.
cp app/index.html.bak app/index.html
docker compose up -dFor a container image rollback, change the image tag in compose.yaml back to the last known good version, then run docker compose up -d again.
11) Check logs, startup persistence, and port binding
On the VPS as the non-root sudo user, confirm the container is restarting correctly and listening where expected.
docker inspect myapp-web --format '{{.HostConfig.RestartPolicy.Name}}'
ss -tulpn | grep ':80'
docker compose logs --tail=30 webThe restart policy should read unless-stopped. The ss output should show a process bound to port 80. Logs should show a healthy Nginx start.
On the VPS as root, confirm Docker starts automatically after reboot.
systemctl is-enabled docker
systemctl is-enabled firewalldBoth should return enabled.
Troubleshooting the most likely failures
- Container starts but is unhealthy: Run
docker compose logs --tail=100 web. If the log says the content directory is unreadable, check the SELinux context withls -ldZ /opt/myapp/appand keep the:Zsuffix in the volume mount. - Port 80 is already in use: Run
ss -tulpn | grep ':80'. Stop the conflicting service or change the Compose port mapping to another host port, then update the firewall before testing again. - Nginx reload fails: Run
sudo nginx -t. Fix the config error shown in the output, then reload only after the syntax test passes. - SSH login works for root but not for deploy: Check ownership and permissions with
ls -ld /home/deploy /home/deploy/.ssh /home/deploy/.ssh/authorized_keys. The home directory should belong todeploy:deploy, andauthorized_keysshould be mode600.
Cleanup and next steps
Once the deployment is stable, you can replace the sample Nginx container with your real service, keep the same directory layout, and use the same rollback discipline for each release. For higher traffic or heavier image builds, moving the same workflow to a Hostperl dedicated server gives you more CPU headroom, disk I/O, and room for backup retention.
If your team ships often, pair this guide with a documented backup routine and log review process. That makes Compose releases much easier to support when a client calls after hours.
Hostperl can host this type of Docker Compose workload on a VPS for smaller deployments or on a dedicated server when you need more consistent I/O and memory headroom. If you want a production-ready environment with responsive support during migrations and cutovers, start with Hostperl VPS or Hostperl dedicated server hosting.
For teams running client projects, this approach keeps releases predictable and recoverable instead of fragile.
FAQ
Can I use this same workflow on Rocky Linux or AlmaLinux?
Yes. The same RHEL-family commands apply on Rocky Linux 9 and AlmaLinux 9. Oracle Linux 9 is similar, but your enabled repositories may differ.
Should I disable SELinux to make containers easier?
No. Keep SELinux enforcing and fix labels with :Z or restorecon when a bind mount is blocked.
Do I need Nginx on the host if the container already serves the site?
Not always. Use the host Nginx only when you need TLS termination, reverse proxying, or shared ports for multiple apps.
What is the safest rollback method?
Save the last working compose.yaml and application files, then restore them and run docker compose up -d again.
