Docker Compose Zero-Downtime Deployments on FreeBSD

What you are building
This tutorial shows you how to run Docker Compose zero-downtime deployments on a FreeBSD server by placing a reverse proxy in front of two app stacks and shifting traffic only after health checks pass. That pattern works well for customer-facing launches, plugin updates, and routine releases where a bad redeploy would interrupt checkout, lead capture, or API traffic.
We are using FreeBSD because this guide is written for that platform, and because FreeBSD hosts often run stable services in smaller fleets where change control matters. If you use Hostperl VPS hosting or a dedicated server, this setup gives you a clean deployment path without Kubernetes or a heavier CI platform.
Keep one rule in mind before you begin: the old container stays live until the new one is verified. That protects you from a broken image, missing environment variable, or failed migration taking the site down.
Scenario, layout, and why Docker Compose zero-downtime deployments work
We will deploy a simple web app in /opt/myapp with two Compose projects: blue and green. A lightweight Nginx proxy on the host listens on ports 80 and 443, then forwards traffic to the active stack on an internal port.
New releases go to the idle stack first. Once the health endpoint responds correctly, we switch the proxy upstream and reload Nginx.
This tutorial uses a FreeBSD host, so the package manager is pkg, the firewall is pf, and service management uses rc.d. The containers still run through Docker, but the host-side controls remain FreeBSD-native. If you want a deeper primer on app release safety, Hostperl also has Docker Compose rollbacks for safer app releases, which covers the same deployment logic on Ubuntu.
Prerequisites and version check
Use a fresh FreeBSD server with root access, a domain name pointing to the server, and a container image that already exposes a health endpoint. The examples use example.com, server.example.com, and 203.0.113.10 as documentation values. Replace them with your real hostname and public IP.
On your local computer: connect to the server
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 SSH account, connect with that account instead and then elevate with su - or doas.
On the VPS as root: detect the operating system
freebsd-versionThis confirms that you are on a supported FreeBSD release. You should also see the release branch in the output, such as 14.2-RELEASE.
On the VPS as root: update the base system and packages
freebsd-update fetch install
pkg update
pkg upgrade -yfreebsd-update applies OS security fixes. pkg refreshes and upgrades packages. If a kernel update was installed, plan a reboot before continuing.
On the VPS as root: create a non-root administrator
pw useradd deploy -m -s /bin/sh -G wheel
passwd deploy
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chown -R deploy:deploy /home/deploy/.sshThis creates the deploy account, adds it to the wheel group, and prepares SSH key storage. Use a strong temporary password if you need password-based onboarding, then replace it with keys as soon as possible.
On your local computer: copy your SSH key
ssh-copy-id deploy@203.0.113.10Replace 203.0.113.10 with your server IP. If ssh-copy-id is unavailable, append your public key manually to /home/deploy/.ssh/authorized_keys on the server and set permissions to 600.
On the VPS as root: lock down ownership and permissions
touch /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keysThe file must be readable only by deploy. If the permissions are too open, SSH will ignore the key.
On your local computer: open a second terminal and test the new login
ssh deploy@203.0.113.10Keep the original root session open until this login works. After you connect, confirm sudo-style privilege with the FreeBSD equivalent:
su -
whoami
idYou should see deploy first, then root after su -. That proves the new admin path works before you change any root-login policy.
Install Docker and Docker Compose on FreeBSD
FreeBSD supports Docker through a Linux compatibility path rather than native container runtime support. For production use, confirm your image choice and host compatibility first.
If your workload is not comfortable with that model, a Linux VPS or dedicated server may be the better fit. Hostperl’s dedicated server hosting is often the right choice when you need more predictable storage and steady release throughput.
On the VPS as root: install the required packages
pkg install -y docker docker-compose git curl sudoThis installs Docker Engine, Docker Compose, Git for release pulls, and curl for health checks. Package names can shift with repository updates, so confirm the package list on your exact FreeBSD release if a name changes.
On the VPS as root: enable the services
sysrc docker_enable=YES
sysrc docker_users="deploy"
service docker startdocker_users allows the deploy account to talk to the Docker daemon without logging in as root. If the service fails to start, check the log with service docker status before proceeding.
On the VPS as the non-root sudo user: confirm Docker access
id
docker version
docker compose versionYou should see your user ID and the installed Docker and Compose versions. If Docker access fails, confirm that deploy is listed in /etc/group for the Docker group or in the runtime access list expected by your package build.
Build the application directory and Compose files
We will store the app in /opt/myapp. Keeping release artifacts, environment files, and logs together makes rollback easier during a late-night incident.
On the VPS as the non-root sudo user: create the release structure
sudo mkdir -p /opt/myapp/{releases,shared,compose}
sudo chown -R deploy:deploy /opt/myapp
cd /opt/myapp
pwdYou should end up in /opt/myapp. The directory tree keeps each release isolated, so the previous version remains available if the next one fails.
On the VPS as the non-root sudo user: create the environment file
cat > /opt/myapp/shared/app.env <<'EOF'
APP_ENV=production
APP_NAME=myapp
APP_PORT=8000
EOF
chmod 600 /opt/myapp/shared/app.envThis file keeps runtime settings out of the Compose YAML. Tight permissions reduce the chance of accidental secret exposure. Add real secrets here only if your application requires them.
On the VPS as the non-root sudo user: create the application Compose file
cat > /opt/myapp/compose/docker-compose.yml <<'EOF'
services:
app:
image: ghcr.io/example/myapp:1.0.0
env_file:
- /opt/myapp/shared/app.env
expose:
- "8000"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 10s
timeout: 3s
retries: 5
EOFReplace ghcr.io/example/myapp:1.0.0 with your real image. The health check is the important part here. The proxy should never switch traffic to a release that cannot answer /health reliably.
On the VPS as the non-root sudo user: create the blue and green Compose wrappers
cat > /opt/myapp/compose/blue.yml <<'EOF'
services:
app:
extends:
file: /opt/myapp/compose/docker-compose.yml
service: app
container_name: myapp-blue
ports:
- "127.0.0.1:18080:8000"
EOF
cat > /opt/myapp/compose/green.yml <<'EOF'
services:
app:
extends:
file: /opt/myapp/compose/docker-compose.yml
service: app
container_name: myapp-green
ports:
- "127.0.0.1:18081:8000"
EOFThese wrappers publish the same app on two different loopback ports. Blue stays live while green receives the next release, and then the proxy flips to the new port after verification.
Bring up the first release and test it locally
On the VPS as the non-root sudo user: start the blue stack
cd /opt/myapp/compose
docker compose -f blue.yml up -dThis starts the first active release. Use blue as the baseline so you always have a known-good instance to fall back to.
On the VPS as the non-root sudo user: confirm the container and health endpoint
docker ps
curl -fsS http://127.0.0.1:18080/healthThe container should show Up, and the curl command should return a healthy response. If either command fails, check docker logs myapp-blue before adding the proxy.
Install and configure Nginx as the traffic switch
Nginx handles the public ports and hides the release swap from users. If you are also working on HTTP tuning or a different reverse-proxy pattern, Hostperl’s Nginx reverse proxy for Node.js apps on Hostperl VPS is a useful companion guide.
On the VPS as root: install Nginx and enable it
pkg install -y nginx
sysrc nginx_enable=YES
service nginx startNginx will sit in front of Docker and forward requests to the active loopback port. If port 80 is already taken, stop the conflicting service before continuing.
On the VPS as root: create the proxy configuration
cat > /usr/local/etc/nginx/nginx.conf <<'EOF'
worker_processes 1;
error_log /var/log/nginx/error.log;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
upstream myapp_active {
server 127.0.0.1:18080;
}
server {
listen 80;
server_name example.com server.example.com;
location / {
proxy_pass http://myapp_active;
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;
}
}
}
EOFReplace example.com and server.example.com with your own domain names. The upstream currently points to blue. Later, we will change that single line to green.
On the VPS as root: test syntax before reloading
nginx -t
service nginx reloadThe syntax test must pass before reload. If it fails, the error message usually names the exact file and line number.
Prepare the idle stack and cut traffic safely
Now the deployment pattern becomes useful. You will start green, confirm it independently, then switch Nginx to the new port after the application proves it is healthy.
On the VPS as the non-root sudo user: start the green stack
cd /opt/myapp/compose
docker compose -f green.yml up -dGreen is the idle release until you switch the proxy. If your app needs database migrations, run them against the new build before the cutover and keep the old schema compatible until the switch completes.
On the VPS as the non-root sudo user: verify green directly
docker ps
curl -fsS http://127.0.0.1:18081/healthGreen should answer the same health check. If it does not, stop here and fix the release. Do not touch the proxy yet.
On the VPS as root: switch Nginx to green
sed -i '' 's/127.0.0.1:18080/127.0.0.1:18081/' /usr/local/etc/nginx/nginx.conf
nginx -t
service nginx reloadThis changes the active upstream from blue to green. The syntax check protects you from a bad edit before the reload happens.
On the VPS as root: confirm the new upstream is live
curl -fsS http://127.0.0.1
service nginx statusThe page should come from the green stack. If users report stale content, check whether the app caches responses or whether an upstream load balancer is still pointing at the previous port.
Firewall, logs, and rollback
FreeBSD commonly uses pf for host firewalling. If your environment already filters traffic upstream, keep the host policy aligned so the proxy ports stay reachable and the loopback-only container ports stay private.
On the VPS as root: enable pf with a minimal ruleset
cat > /etc/pf.conf <<'EOF'
set skip on lo
pass in on egress proto tcp to port { 22, 80, 443 }
pass out all keep state
EOF
sysrc pf_enable=YES
service pf restartThis allows SSH and web traffic while leaving the container ports bound only to loopback. If you use another interface name instead of egress, replace it with the correct WAN interface from ifconfig.
On the VPS as root: check the logs after cutover
tail -n 50 /var/log/nginx/access.log
tail -n 50 /var/log/nginx/error.log
docker logs myapp-green --tail 50You want to see normal 200 responses, no upstream connection errors, and no application stack traces. Logs tell you quickly whether the problem is Nginx, the container, or the app itself.
On the VPS as the non-root sudo user: roll back if needed
sudo sed -i '' 's/127.0.0.1:18081/127.0.0.1:18080/' /usr/local/etc/nginx/nginx.conf
sudo nginx -t
sudo service nginx reloadThis returns traffic to blue. Keep the failed green stack running until you have its logs and understand the fault. Then stop it cleanly:
docker compose -f /opt/myapp/compose/green.yml downThat sequence preserves service continuity. It also gives you a known-good fallback whenever a deployment needs to be reversed during business hours.
Make the deployment persistent across reboot
Two checks matter here: the host services should restart automatically, and the containers should start again without manual intervention.
On the VPS as root: confirm startup flags
sysrc -n docker_enable
sysrc -n nginx_enableBoth commands should return YES. That means Docker and Nginx are set to start on boot.
On the VPS as the non-root sudo user: confirm the app container restart policy
docker inspect myapp-green --format '{{.HostConfig.RestartPolicy.Name}}'You should see unless-stopped. If you need to rebuild after a reboot, the same Compose files in /opt/myapp/compose remain ready to use.
Troubleshooting the most likely failures
Most deployment problems fall into a small set of categories. Use the command that matches the symptom, read the clue, then apply the fix.
- Nginx reload fails: run
nginx -t. If the output names a line number, correct the config file and test again before reloading. - Container starts but health fails: run
docker logs myapp-green --tail 100. Missing environment variables or a wrong internal port usually show up here. - Cannot reach the site from the internet: run
service pf statusandsockstat -4 -l. If 80 is not listening or pf is blocking it, fix the host firewall first. - Old content still appears after cutover: run
curl -I http://127.0.0.1and check for caching headers or a stale upstream in the Nginx config. - Docker access denied for deploy: run
id deployand confirm the user can access the Docker daemon path expected by your package build.
Final verification from server and client
On the VPS as the non-root sudo user
docker ps
curl -fsS http://127.0.0.1/health
service nginx status
sockstat -4 -l | grep ':80'These commands confirm the container is running, the health endpoint responds, Nginx is active, and port 80 is listening on the host.
On your local computer
curl -I http://203.0.113.10Replace 203.0.113.10 with your real server IP. A successful response should return HTTP headers from the active stack. If you later add TLS, repeat the test with https://example.com after issuing a certificate.
If you are rolling out customer-facing releases, Hostperl can host the app stack on a VPS or a dedicated server with enough headroom for blue-green deployment and rollback testing. For heavier traffic or storage-intensive workloads, compare Hostperl VPS with dedicated server hosting before you commit to a release pattern.
FAQ
Can I use this with a private registry image?
Yes. Authenticate with docker login before the first up -d, and keep the image tag pinned so blue and green run the same build when needed.
Do I need Kubernetes for zero-downtime releases?
No. For many hosting customers, Docker Compose plus a reverse proxy is simpler, cheaper, and easier to support. It also works well on smaller production servers.
What if my app needs a database migration?
Run backward-compatible migrations before the cutover, then switch traffic after the new release passes health checks. Keep the old stack available until the migration is verified.
Is FreeBSD the best platform for every container app?
No. If your image depends on Linux-only behavior or a specific kernel feature, use a Linux VPS or dedicated server instead. FreeBSD is a good fit only when your stack and operational model match it.
For teams that want predictable cutovers and clear rollback paths, Hostperl’s operational approach fits this workflow well. Start with a VPS for lower-cost release testing, or choose dedicated server hosting when you need more consistent IO and room for multiple active stacks.
