Migrate a Docker Website to a New Server

Recently, because I changed hosting provider, I had to migrate a Docker based website from an old server to a new server. I took some notes during the process and decided to write a guide to help others who may be in a similar situation.

It provides step-by-step instructions to ensure a smooth transition with minimal to no downtime. These instructions were performed on Ubuntu Server 24.04.4 LTS, but they can be used or adapted for other operating systems. The DNS change was done using Cloudflare, but the steps can be adjusted for other DNS providers.

#New Server Setup

#Basic Updates and Upgrades

First, log in to your new server using SSH as the root user:

ssh root@your_server_ip_address

Before starting the migration process, it’s important to ensure that your new server is up to date with the latest security patches and software updates. Run the following commands to update the package lists and upgrade installed packages:

sudo apt update
sudo apt upgrade -y

#Basic Security

#Add a New User with sudo Privileges

Logging into your new server via SSH with the root user, as we did in the previous step, is not recommended for security reasons. Instead, create a new user with sudo privileges.

Adding a new user to the system. In this example, we’ll create a user named johndoe, but you can choose any name you prefer. You will be prompted to write the user password. Run the following command:

adduser johndoe

Adding the user to the sudo group. This ensures they can perform administrative tasks like installing Docker, managing containers, or configuring the server without needing full root access.

Change johndoe to your user name:

usermod -aG sudo johndoe

Testing sudo access. Change johndoe to your user name:

su - johndoe
sudo ls -la /root

#Disable root SSH Login

To enhance security, it’s a good practice to disable root login via SSH. This prevents attackers from attempting to log in as the root user directly. First, open the SSH configuration file:

sudo nano /etc/ssh/sshd_config

Find the line that says PermitRootLogin yes and change it to:

PermitRootLogin no

Restart the SSH service:

sudo systemctl restart ssh

#Change SSH Port

Changing the default SSH port (22) to a non-standard port can help reduce the risk of automated attacks. First, choose a new port number that is not already in use (e.g., 44441). Make sure to choose a port number above 1024 to avoid conflicts with well-known ports.
Open the SSH configuration file:

sudo nano /etc/ssh/sshd_config

Change the port configuration:

Port 44441

Before restarting the service, test that your configuration is valid:

sudo sshd -t

Then restart the service:

sudo systemctl restart ssh

Note: Ubuntu 24.04 ships openssh-server with socket activation enabled by default (ssh.socket listening on port 22). When a connection is triggered through ssh.socket, the spawned ssh.service inherits the listening socket from systemd and ignores the Port directive in sshd_config. However, running sudo systemctl restart ssh starts ssh.service directly (not via socket activation), so that instance binds its own listener using the Port value from sshd_config. This means both can end up listening at the same time: ssh.socket still active on port 22, and a directly-started sshd process on your custom port (e.g., 44441), which is why you may be able to connect on the new port even while ssh.socket shows as active. You can confirm this with sudo ss -tlnp | grep sshd, which should show a listener on port 22 owned by systemd and another on your custom port owned by sshd. To avoid this dual-listening setup and rely solely on sshd_config, check if ssh.socket is active:

systemctl is-active ssh.socket

If it returns active, either disable socket activation so ssh.service reads sshd_config directly:

sudo systemctl disable --now ssh.socket
sudo systemctl enable --now ssh.service
sudo systemctl restart ssh

Or override the socket’s listen port instead:

sudo systemctl edit ssh.socket

And add the following to override the default port (replace 44441 with your chosen port), you first “reset” the existing ListenStream directive and then specify the new port:

[Socket]
ListenStream=
ListenStream=44441

Then reload the socket:

sudo systemctl daemon-reload
sudo systemctl restart ssh.socket

#Set Up SSH Key Authentication

SSH key authentication is more secure than password authentication because it relies on cryptographic keys rather than passwords, which can be guessed or brute-forced. To set up SSH key authentication, follow these next steps on your local machine.

The following command will create a public key (~/.ssh/id_ed25519_personal_servers.pub) and a private key (~/.ssh/id_ed25519_personal_servers): You will be prompted to enter a passphrase for added security, you can leave it empty if you prefer, but it’s recommended to use a passphrase in case your private key is compromised.

On macOS, for a stronger setup with Keychain support, use the following two commands: It will store the private key passphrase in the macOS Keychain, so you usually won’t have to enter it every time you use the key. It works reliably when your SSH config (~/.ssh/config) includes UseKeychain yes and AddKeysToAgent yes.

ssh-keygen -t ed25519 -a 100 -f ~/.ssh/id_ed25519_personal_servers -C "[email protected]"
ssh-add --apple-use-keychain ~/.ssh/id_ed25519_personal_servers

Add this to your ~/.ssh/config so it persists and auto-loads nicely:

Host *
AddKeysToAgent yes
UseKeychain yes
IdentityFile ~/.ssh/id_ed25519_personal_servers

Note: As this local machine is used for personal servers, I associated the key ~/.ssh/id_ed25519_personal_servers to all hosts with Host *, but you can also use a specific host alias instead of * if you prefer.

On other systems (not macOS), you can use the following command to generate the SSH key pair:

ssh-keygen -t ed25519 -a 100 -f ~/.ssh/id_ed25519_personal_servers -C "[email protected]"
ssh-add ~/.ssh/id_ed25519_personal_servers

If you want to use it on a specific server, you can add a specific host entry in your ~/.ssh/config file: And you connect to your server using the alias your_server_alias instead of the IP address, which is more convenient. Like ssh your_server_alias.

Host your_server_alias
HostName your_server_ip_address
User johndoe
Port 44441
IdentityFile ~/.ssh/id_ed25519_personal_servers

Then, from your local machine, copy the public key to your server. This command will ask for your user password of the server and will copy your public key to the server and add it to the ~/.ssh/authorized_keys file, on the server, for the user johndoe:

ssh-copy-id -i ~/.ssh/id_ed25519_personal_servers.pub -p 44441 johndoe@your_server_ip_address

Make sure to replace 44441 with your chosen SSH port and johndoe with the username on your server. After copying the key, you can test SSH key authentication by logging in to your server. No password should be required if the key authentication is set up correctly, because the SSH client will use your private key to authenticate with the server:

ssh -p 44441 johndoe@your_server_ip_address

After setting up SSH key authentication, it’s recommended to disable password authentication to enhance security.

Make sure you have successfully logged in using SSH keys before disabling password authentication!
This is because you may lock yourself out of the server if you disable it without having SSH key access set up correctly. If you got locked out, you may need to access your server through a web console provided by your hosting provider to fix the SSH configuration. Typically, this involves enabling password authentication temporarily or adding a new SSH public key to the authorized_keys file (~/.ssh/authorized_keys).

Follow these next steps on your server machine.

First, open the SSH configuration file:

sudo nano /etc/ssh/sshd_config

Find the line that says PasswordAuthentication yes and change it to:

PasswordAuthentication no

Before restarting the service, test that your configuration is valid:

sudo sshd -t

Then restart the service:

sudo systemctl restart ssh

Note: /etc/ssh/sshd_config.d/50-cloud-init.conf often contains PasswordAuthentication yes which overrides the main sshd_config we just edited. After disabling password auth, check if that file still has it enabled:

sudo grep "PasswordAuthentication yes" /etc/ssh/sshd_config.d/50-cloud-init.conf

If it returns any results, you may need to edit that file and set PasswordAuthentication no as well.

sudo nano /etc/ssh/sshd_config.d/50-cloud-init.conf

#Configure a Firewall - UFW (Uncomplicated Firewall)

UFW (Uncomplicated Firewall) is essential for securing a new server because it provides a critical first line of defense against network-based attacks. A fresh server typically has all ports open or relies on minimal built-in protections. UFW lets you quickly establish a “deny by default” policy, blocking all incoming connections except those you explicitly allow. To install UFW and set it up on your Ubuntu server, follow these steps.

Install UFW:

sudo apt install ufw

Check if your Ubuntu server has IPv6 enabled:

ip -6 addr show

If IPv6 is enabled, you’ll see IPv6 addresses (starting with fe80:: for link-local or other IPv6 addresses). If it’s enabled ensure that UFW is configured to support IPv6 so that it will manage firewall rules for IPv6 in addition to IPv4. To do this, open the UFW configuration with nano or your favorite editor and set IPV6=yes:

sudo nano /etc/default/ufw
# Set to yes to enable ipv6 support. UFW will use ip6tables instead of iptables.
IPV6=yes

Setting up default policies. Block all incoming traffic and allow all outgoing traffic by default:

sudo ufw default deny incoming
sudo ufw default allow outgoing

Allow inbound services, ufw allow creates rules for inbound traffic by default:

sudo ufw allow 44441/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

sudo ufw enable

Restart the server:

sudo reboot

After the server restarts, test if you can still connect to the server via SSH on the port you configured (e.g., 44441).

ssh -p 44441 johndoe@your_server_ip_address

#Fail2Ban

Fail2Ban is a log-parsing tool that scans log files for suspicious activity and bans IP addresses that show signs of malicious behavior, such as repeated failed login attempts. It helps protect against brute-force attacks and other automated threats by temporarily blocking IP addresses that exhibit suspicious behavior. Fail2Ban is particularly useful for protecting services like SSH, web servers, and other applications that log authentication attempts.

It uses the concept of jails, jails are rule sets for protecting a specific service. Example: An sshd jail watches SSH auth logs. If an IP fails login 3 times, ban it for 1 hour. So think of a jail as: detector + threshold + punishment for one service.

Install Fail2Ban on Ubuntu:

sudo apt update
sudo apt install fail2ban

Start and enable the service:

sudo systemctl start fail2ban
sudo systemctl enable fail2ban

Fail2Ban is now installed and running on your server. You can check its status with:

sudo systemctl status fail2ban

The main config files are in /etc/fail2ban/. You’ll want to:

  1. Copy /etc/fail2ban/jail.conf to /etc/fail2ban/jail.local.
  2. Edit /etc/fail2ban/jail.local for your custom settings:
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local

The jail.local file is where you can enable and configure jails for specific services. Some distributions (like Ubuntu) enable the SSH jail by default even without explicitly setting enabled = true. If you want to check if the SSH jail is enabled, you can run the following commands to search for enabled = true in the configuration files. If it returns any results, it means the SSH jail is enabled:

grep -r "enabled.*true" /etc/fail2ban/jail.d/
grep -r "enabled.*true" /etc/fail2ban/jail.conf

But if you want to ensure that the SSH jail in your Linux distribution is enabled, you can add or modify the following lines in your /etc/fail2ban/jail.local file:

[sshd]
enabled = true
port = 44441
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600

Note: On Ubuntu 24.04 the default Fail2Ban auto backend uses journald, so logpath is not needed, that line in the configuration can be omitted. /var/log/auth.log is populated (rsyslog writes it), but fail2ban isn’t reading it. Either way I like to keep it there.

Note: bantime is in seconds, so 3600 seconds = 1 hour. You can adjust it to your preference.

After making changes, just restart fail2ban:

sudo systemctl restart fail2ban

Check the status of Fail2Ban:

sudo fail2ban-client status sshd

#Install Docker

I’m a big fan of Docker and I use it on most of my projects, here are the steps to install Docker on the new server.

Install prerequisites:

sudo apt install ca-certificates curl software-properties-common -y

Add Docker’s official GPG key:

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

Add Docker repository:

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Update package index and install Docker:

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y

The installation of Docker also installs the compose plugin, which allows you to use docker compose commands without needing to install the separate docker-compose tool. We will use docker compose in the next steps to manage our Docker services.

Start and enable Docker:

sudo systemctl start docker
sudo systemctl enable docker

Add your current logged user to the docker group (optional but recommended):

sudo usermod -aG docker $USER

Docker group allows users to run Docker commands without sudo, but it also grants elevated privileges, so use it with caution. It’s root equivalent.

Log out and back in for the group change to take effect, or run:

newgrp docker

Verify installation:

docker --version

If your docker-compose file specifies a specific network, you may need to create it before running the services. In my case the docker-compose file specifies a network named web, so I run:

docker network create web

#Migrate Server Content

#Copy Files to the New Server (Before DNS Change)

Copy the project files from the old server to the new server using rsync to ensure that all files are transferred and kept in sync. This will also copy the docker-compose.yml file and any other necessary configuration files to the new server.

From old server, sync to new server. --delete removes destination files not present in source, verify paths first:

rsync -avz --delete -e "ssh -p 44441" /path/to/project/ johndoe@new-server:/path/to/project/

One of my docker services depends on a private image, so I had to make sure that I copied the tar.gz file of the image to the new server and load it before starting the services on the new server:

From old server, copy the private image to new server and load it before starting the services on the new server:

docker save private-image-name:tag | gzip > private-image-name.tar.gz
scp -P 44441 private-image-name.tar.gz johndoe@new-server:/path/to/project/
ssh -p 44441 johndoe@new-server "docker load -i /path/to/project/private-image-name.tar.gz"

From the old server, start services on the new server:

ssh -p 44441 johndoe@new-server "cd /path/to/project && docker compose up -d"

Verify that the services are running on the new server:

ssh -p 44441 johndoe@new-server "docker compose ps"

In my use case it was several services, including an Nginx reverse proxy, so I had to make sure that the nginx configuration was correct. Because the site maintained the same domain name, the SSL certificate was still valid and I didn’t have to change anything in the nginx configuration related to SSL.

#Migration Day

Before the DNS change, test the new server to ensure everything is working correctly.

Test HTTP (direct to origin server, bypassing Cloudflare):

curl -I http://NEW_SERVER_IP

Test HTTPS (direct via Let’s Encrypt, bypassing Cloudflare):

The -k flag (--insecure) is needed because the Let’s Encrypt cert is issued for your domain name, not for the raw IP address.

curl -k -I https://NEW_SERVER_IP

You can also add to your local /etc/hosts the mapping between the new server IP and your domain name temporarily to test with the proper domain name. First, add this line to your /etc/hosts file:

NEW_SERVER_IP yourdomain.com

This way you don’t need the -k flag and you can test the SSL certificate properly with the domain name:

curl -I https://yourdomain.com

Verify both sites (on both servers) are working.

Standard DNS resolution (should still point to the old server until DNS change propagates):

curl -I https://yourdomain.com

Overriding the standard DNS resolution (--resolve option allows you to provide custom IP addresses for hostnames, overriding the standard DNS resolution):

curl -I https://yourdomain.com --resolve yourdomain.com:443:NEW_SERVER_IP

In Cloudflare, set DNS > Records to DNS only (gray cloud icon) during migration. To change DNS in Cloudflare update the A record to the NEW_SERVER_IP.

During migration with DNS only (gray cloud), traffic goes directly to your origin server, and Cloudflare SSL/TLS mode does not affect user requests. After cutover validation passes, switch Cloudflare DNS record to proxied (orange cloud) This reduces troubleshooting complexity during the critical switch.

After enabling Cloudflare proxy (orange cloud), set SSL/TLS > Overview to Full (strict). In this mode, visitors connect securely to Cloudflare, and Cloudflare connects securely to your server over HTTPS while validating your origin certificate. Use this mode when your server has a valid SSL certificate (such as Let’s Encrypt).

Using Cloudflare in proxied mode gives you a strong security and performance upgrade in one step: it hides your origin IP, filters malicious traffic with WAF and DDoS protection, and speeds up delivery through its global CDN and edge optimizations. It also improves HTTPS posture, because visitors terminate TLS at Cloudflare while Full (strict) enforces a valid certificate between Cloudflare and your server. Cloudflare gives you safer end-to-end encryption with better reliability and simpler operations like automatic DDoS protection, caching and certificate renewal for the public-facing certificate.

Because Cloudflare’s proxy only supports certain HTTP/HTTPS ports, you cannot use it for SSH on a custom port (e.g., 44441). To keep SSH accessible, create a new A record (e.g., ssh.yourdomain.com) pointing to your NEW_SERVER_IP and set it to DNS only (gray cloud). This way Cloudflare acts only as the DNS resolver, and you connect directly to your origin server on port 44441 using that hostname: ssh -p 44441 [email protected] The main website traffic continues through the proxied (orange cloud) record, protected by Cloudflare.

After the DNS change, open your browser and visit your website to verify that it is loading correctly from the new server. Monitor the traffic and logs on the new server to ensure that everything is working correctly and there are no issues with the migration. Your site should now be fully migrated to the new server with minimal to no downtime.

#Security and Performance Audits

#Firewall and Exposed Surface

Check UFW status and rules:

sudo ufw status verbose

#Open Ports List

That command will show you all processes listening on network ports:

sudo lsof -i -P -n | grep LISTEN

This will display all listening TCP and UDP ports with the processes that own them:

sudo ss -plunt | column -t

#See Login Attempts

sudo grep "Failed password" /var/log/auth.log

#Fail2Ban

See which jails are active, you can use the following command:

sudo fail2ban-client status

Or see the stats of a specific jail, for example sshd:

sudo fail2ban-client status sshd

See list of banned IPs for a specific jail, for example sshd:

sudo fail2ban-client get sshd banip

Unban an IP:

sudo fail2ban-client set sshd unbanip <IP_ADDRESS>

Some useful commands to check the SSH jail configuration:

sudo fail2ban-client get sshd bantime
sudo fail2ban-client get sshd maxretry
sudo fail2ban-client get sshd findtime

View fail2ban logs:

sudo tail -f /var/log/fail2ban.log

Test if it’s monitoring the right log file:

sudo fail2ban-client get sshd logpath

Should show something like /var/log/auth.log or /var/log/secure.

#SSH Logs

View sshd entries in the last 500 lines of the log:

sudo tail -n 500 /var/log/auth.log | grep 'sshd'

#System Integrity and Update Posture

Check for available updates:

sudo apt update

Check for available security updates:

sudo apt list --upgradable | grep security

#CPU, Memory and Disk Pressure

Check CPU:

top -b -n 1 | head -n 10

Check memory:

free -h

Or, if you prefer, install htop for a more interactive way to monitor CPU and memory resources. Install it and run it with the following commands:

sudo apt install htop
htop

Check disk usage:

df -h

#HTTP and TLS Behavior Checks

Check HTTP and HTTPS response headers and TLS certificate information with the following commands:

curl -I http://yourdomain.com
curl -I https://yourdomain.com
echo | openssl s_client -connect yourdomain.com:443 -servername yourdomain.com 2>/dev/null | openssl x509 -noout -dates -issuer -subject

#Conclusion

Migrating a Docker website to a new server means securing the new machine, installing Docker, and moving your app files and configuration. After migration, test everything carefully and monitor the server for issues.

These steps were performed on Ubuntu Server 24.04.4 LTS, during the beginning of 2026, and they may need to be adjusted for future versions of Ubuntu or other operating systems as well as for the Cloudflare configuration. But they should provide a solid foundation for migrating a Docker website for some time. Ubuntu Server 24.04.4 LTS is a long-term support release, so it will receive updates and security patches until April 2029.

You should now have a fully migrated Docker website running on your new server.

Date: