Blue-Green Docker Deployments on RHEL 9 VPS

What this deployment pattern solves
Blue-Green Docker deployments let you launch a new app version on a second stack, test it on the live server, and move traffic only after health checks pass. On a RHEL 9 VPS, that matters for customer-facing apps, agency cutovers, and any release window where downtime is not an option.
This tutorial is written for a fresh RHEL 9, Oracle Linux 9, or compatible RHEL-family server. If you want a managed VPS built for this kind of release workflow, start with Hostperl VPS. For release planning context, pair this guide with Docker Compose release readiness for VPS launches.
You will create a non-root admin user, install Docker Compose, run two app instances, place Nginx in front as the traffic switch, and keep rollback straightforward. The sample app listens on port 8000 inside the container, while Nginx exposes 80 and 443 to the internet.
Architecture and release flow
We will use two application directories: /opt/myapp/blue and /opt/myapp/green. Only one serves production traffic at a time. Nginx proxies to the active stack on localhost, so the cutover happens by changing one upstream file and reloading Nginx.
- Blue: current production stack
- Green: new version under test
- Nginx: public entry point and cutover layer
- Systemd: starts Docker on boot
- Firewalld: opens only the ports you need
If you want a quick check on platform fit and sizing before release work starts, Hostperl’s general hosting guidance is easier to apply operationally than generic developer advice. A second useful reference is Docker deployment checklist for safer Hostperl launches.
1) Connect to the server and confirm the OS
On your local computer
ssh root@203.0.113.10Replace 203.0.113.10 with the public IP assigned to your server. This documentation IP is only an example.
If your provider gave you a default non-root account, use that instead and then elevate with sudo:
ssh deploy@203.0.113.10On the VPS as root
cat /etc/os-releaseConfirm you are on RHEL 9, Oracle Linux 9, or a close RHEL-compatible system. The commands below assume a RHEL-family base.
2) Create the deploy user and lock down SSH access
On the VPS as root
useradd -m -G wheel -s /bin/bash deployThis creates the admin account. Set a password now so you still have a local recovery path if SSH keys fail:
passwd deployInstall sudo access through the wheel group and prepare the SSH directory:
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chown -R deploy:deploy /home/deploy/.sshAdd your public key. Replace the key text with your own key from your local computer:
cat > /home/deploy/.ssh/authorized_keys <<'EOF'
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM7examplekeyvaluehere user@laptop
EOF
chmod 600 /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keysKeep the root session open. Open a second terminal and test login before changing any SSH policy.
On your local computer
ssh deploy@203.0.113.10Then confirm sudo works:
sudo -v
whoamiYou should see deploy for the username and no sudo error. Only after this succeeds should you harden SSH further.
3) Update packages, time sync, and Docker
On the VPS as the non-root sudo user
sudo dnf -y updateInstall Docker, Compose, Nginx, and utilities:
sudo dnf -y install dnf-plugins-core docker nginx firewalld curl git rsync policycoreutils-python-utilsEnable and start the services:
sudo systemctl enable --now docker nginx firewalld chronydCheck versions so you know the installation actually landed:
docker --version
docker compose version
nginx -v
systemctl status docker --no-pager
On RHEL-family systems, SELinux is usually enforcing. Leave it that way. We will label the files correctly instead of turning enforcement off.
4) Open the firewall safely
On the VPS as the non-root sudo user
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-allThis adds the new rules before you touch anything else. You should see SSH, HTTP, and HTTPS in the active zone. Do not remove your current access method until the new one works.
5) Build the blue-green app layout
On the VPS as the non-root sudo user
sudo mkdir -p /opt/myapp/{blue,green}
sudo chown -R deploy:deploy /opt/myapp
cd /opt/myapp
tree -L 2Create a small test app image that serves a release label. That keeps the deployment process repeatable and makes cutover checks obvious.
On the VPS as the non-root sudo user
cd /opt/myapp
cat > Dockerfile <<'EOF'
FROM nginx:1.27-alpine
RUN printf 'server {\n listen 8000;\n location / {\n add_header Content-Type text/plain;\n return 200 "release=$RELEASE\n";\n }\n}\n' > /etc/nginx/conf.d/default.conf
CMD ["nginx", "-g", "daemon off;"]
EOFCreate the first compose file for blue:
On the VPS as the non-root sudo user
cat > /opt/myapp/blue/compose.yml <<'EOF'
services:
app:
build:
context: ..
environment:
RELEASE: blue
ports:
- "127.0.0.1:18080:8000"
EOFCreate the green variant:
On the VPS as the non-root sudo user
cat > /opt/myapp/green/compose.yml <<'EOF'
services:
app:
build:
context: ..
environment:
RELEASE: green
ports:
- "127.0.0.1:28080:8000"
EOFBring up blue first:
On the VPS as the non-root sudo user
cd /opt/myapp/blue
sudo docker compose -f compose.yml up -d --build
sudo docker ps
curl http://127.0.0.1:18080You should receive release=blue. That confirms the production stack is alive on localhost before Nginx gets involved.
6) Configure Nginx as the traffic switch
Create a simple upstream file that points Nginx at the active stack. Start with blue.
On the VPS as the non-root sudo user
sudo tee /etc/nginx/conf.d/myapp-upstream.conf </dev/null <<'EOF'
upstream myapp_active {
server 127.0.0.1:18080;
}
EOFCreate the site config:
On the VPS as the non-root sudo user
sudo tee /etc/nginx/conf.d/myapp.conf </dev/null <<'EOF'
server {
listen 80;
server_name server.example.com;
location /healthz {
proxy_pass http://myapp_active;
proxy_set_header Host $host;
}
location / {
proxy_pass http://myapp_active;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOFTest syntax before reloading:
On the VPS as the non-root sudo user
sudo nginx -t
sudo systemctl reload nginxVerify from the server:
On the VPS as the non-root sudo user
curl -i http://127.0.0.1/healthzYou should see HTTP 200 and release=blue. If SELinux blocks proxying, inspect the logs next.
7) Pre-stage green and compare it before cutover
On the VPS as the non-root sudo user
cd /opt/myapp/green
sudo docker compose -f compose.yml up -d --build
sudo docker ps
curl http://127.0.0.1:28080That output should show release=green. Now you can test the new release privately without touching public traffic.
For database-backed apps, this is the point where you would validate schema changes or migration scripts. If your deployment also needs PostgreSQL backup planning, Hostperl’s recovery notes are a useful companion: PostgreSQL backups and restores for VPS recovery.
8) Cut traffic from blue to green
Swap the upstream file to point at the green container, then reload Nginx. This is the actual blue-green switch.
On the VPS as the non-root sudo user
sudo tee /etc/nginx/conf.d/myapp-upstream.conf </dev/null <<'EOF'
upstream myapp_active {
server 127.0.0.1:28080;
}
EOF
sudo nginx -t
sudo systemctl reload nginxCheck the live endpoint:
On the VPS as the non-root sudo user
curl -i http://127.0.0.1/healthzYou should now see release=green. From a second terminal on your local computer, test the public IP or DNS name:
On your local computer
curl -i http://203.0.113.10/healthzReplace the example IP with your real server address. The response should match the green release.
9) Add HTTPS with Let’s Encrypt
If your domain already points to the server, install Certbot and issue a certificate. On RHEL 9, use the EPEL package first.
On the VPS as the non-root sudo user
sudo dnf -y install epel-release
sudo dnf -y install certbot python3-certbot-nginxRequest the certificate. Replace server.example.com and example.com with your real host and domain names:
On the VPS as the non-root sudo user
sudo certbot --nginx -d server.example.comCertbot should update the Nginx config and install renewal files. Confirm the timer exists:
On the VPS as the non-root sudo user
systemctl list-timers | grep certbot
sudo certbot renew --dry-runFor DNS, TLS, and mail-delivery follow-on work, Hostperl also keeps a practical DNS and email path documented in DNSSEC and email authentication on Ubuntu Server 24.04. The OS differs, but the validation habits carry over well.
10) Make the deployment survive a reboot
Docker and Nginx are already enabled, but you should confirm persistence by rebooting once during a maintenance window. That catches missing boot dependencies before a customer does.
On the VPS as the non-root sudo user
sudo systemctl is-enabled docker nginx firewalld
sudo rebootReconnect after the server comes back:
On your local computer
ssh deploy@203.0.113.10Then verify the active release and service state:
On the VPS as the non-root sudo user
systemctl status docker nginx firewalld --no-pager
sudo docker ps
curl -i http://127.0.0.1/healthzRollback and recovery
If green fails after cutover, switch the upstream back to blue and reload Nginx. That gives you a fast rollback without rebuilding images.
On the VPS as the non-root sudo user
sudo tee /etc/nginx/conf.d/myapp-upstream.conf </dev/null <<'EOF'
upstream myapp_active {
server 127.0.0.1:18080;
}
EOF
sudo nginx -t
sudo systemctl reload nginxIf both releases are broken, keep Nginx pointed at the last known-good container and inspect logs:
On the VPS as the non-root sudo user
sudo journalctl -u nginx -n 50 --no-pager
sudo journalctl -u docker -n 50 --no-pager
sudo docker logs $(sudo docker ps -q --filter publish=18080)
sudo docker logs $(sudo docker ps -q --filter publish=28080)For broader release recovery practice, review WordPress recovery plan for updates, migrations, and rollbacks. The application differs, but the rollback discipline is the same.
Troubleshooting the most common failures
- Nginx returns 502: run
curl http://127.0.0.1:18080andcurl http://127.0.0.1:28080. If one fails, rebuild that stack withsudo docker compose -f compose.yml up -d --build. - SELinux blocks proxying: check
sudo ausearch -m AVC -ts recent. If you see denial messages, confirm the config stays under/etc/nginx/conf.dand the proxy target is on localhost. Do not disable SELinux to hide the problem. - Firewall looks open but the site is unreachable: run
sudo firewall-cmd --list-allandss -tulpn | grep -E ':80|:443'. If Nginx is not listening, checksudo systemctl status nginx. - Compose does not start after reboot: inspect
sudo journalctl -u docker -b --no-pagerand confirm Docker is enabled withsystemctl is-enabled docker.
Final verification checklist
Finish with one server-side and one client-side smoke test.
On the VPS as the non-root sudo user
sudo nginx -t
sudo docker ps
curl -i http://127.0.0.1/healthz
ss -tulpn | grep -E ':80|:443'On your local computer
curl -i http://203.0.113.10/healthzIf you want a more capacity-oriented VPS for release testing, active deployments, and customer workloads, Hostperl’s managed VPS hosting is a better fit than pushing production releases onto under-sized shared resources. That matters when your cutover window is measured in minutes and support response matters.
Hostperl is a practical choice if you want to run Blue-Green Docker deployments without guessing about uptime, firewall policy, or recovery steps. Our VPS hosting gives you the control needed for Docker, Nginx, and clean release cutovers, while our support team can help you plan migrations and rollback windows.
If your app serves customers in NZ or APAC, that extra operational margin matters on release day.
FAQ
Can I use this pattern on Ubuntu or Debian?
Yes. The release flow is the same, but package names, firewall tooling, and SELinux handling change. On Debian-family systems you would use apt and usually UFW instead of firewalld.
Why proxy through Nginx instead of exposing the container port?
Nginx gives you one stable public endpoint, simpler TLS renewal, access logs, and a clean place to switch between blue and green.
What if my app needs a database migration?
Run the migration against the green stack before cutover, then verify it with live reads and writes. If the migration fails, keep blue active and fix the new schema first.
Is this safe for production?
Yes, if you keep blue intact, validate green before switching, test syntax before reloads, and keep rollback commands ready. That is the point of the pattern.
Should I disable root SSH after this?
Only after you have confirmed the deploy user can reconnect from a second terminal and run sudo. Keep a fallback console path if your provider offers one.
