Vitalis

Deploying Docker production on Contabo

Introduction

I’ll take you through how I deployed a Docker application inside a Contabo VPS.

deployment

This blog is mainly aboutdeployment. I won’t go through setting up Docker locally or building the application. I’m assuming you already have those parts ready.

The goal is simple:

  1. Deploy my application to the Contabo instance.

  2. Connect Cloudflare and handle the domain/DNS side.

  3. Put Nginx in front of the application.

  4. Enable HTTPS.

  5. Make sure the server is reasonably secure.

Let’s get started.

Prerequisites

Before we start, make sure you have:

  1. An application ready to deploy.

  2. Adocker-compose.prod.ymlfile.

  3. A Contabo VPS. For this guide, I’m using the lowest instance.

  4. Your application available on GitHub.

  5. A domain name.

  6. And, of course, you’re ready to learn. 😄

The Steps We’ll Follow

Here’s what we’re going to do:

  1. Access the VPS through SSH as root.

  2. Create a new user.

  3. Set up SSH access for the new user.

  4. Create a server setup script.

  5. Run the script.

  6. Configure the firewall.

  7. Pull the application from GitHub.

  8. Configure the environment variables.

  9. Deploy Docker.

  10. Configure Nginx.

  11. Connect the domain.

  12. Generate the SSL certificate.

  13. Disable root SSH access.

  14. Test everything.

Nothing too complicated. Let’s take it one step at a time.

1. Access the VPS Through SSH

First, log in to yourContabodashboard.

On the left sidebar, click:

Servers&Hosting → VPS

Servers&Hosting → VPS

IP address

Select your instance and get theIP address.

Press enter or click to view image in full size

You’ll also need the root password.

If you don’t have the password, select your instance and click the three dots at the end.

Press enter or click to view image in full size

Reset Credentials

ChooseReset Credentialsand set a new password.

Once you’ve done that, open your terminal.

Run:

bash
ssh root@YOUR_SERVER_IP

For example:

bash
ssh root@123.45.678.912

Enter the root password when prompted.

And there we go — we’re inside the server.

Press enter or click to view image in full size

2. Create a User Instead of Using Root

Now that we have access to root, there’s something we need to fix.

Using root for everything isn’t a good idea.

We’re going to create another user that we’ll use for our normal deployment work. Whenever we need administrative access, we’ll usesudo.

I’ll call my userdeploy.

Run:

bash
useradd -m -s /bin/bash -G sudo,docker deploy

Set a password:

bash
passwd deploy

Enter your desired password twice.

Now let’s create the SSH directory:

bash
mkdir -p /home/deploy/.ssh chmod 700 /home/deploy/.ssh

Now we need to configure SSH.

Generate an SSH key:

bash
# Don't generate a private key on the VPS and copy it back to your laptop. # Generate it on the laptop and put only the public key on the VPS ssh-keygen -t ed25519 -C "deploy@example.com"

Save it under:

bash
/home/deploy/.ssh/id_ed25519

Then copy the public key intoauthorized_keys:

bash
cp /home/deploy/.ssh/id_ed25519.pub /home/deploy/.ssh/authorized_keys

Set the correct permissions:

bash
chmod 600 /home/deploy/.ssh/authorized_keys chown -R deploy:deploy /home/deploy/.ssh

Now let’s get the private key:

bash
cat /home/deploy/.ssh/id_ed25519

Copy the entire output.

Keep this private key private. Don’t send it to anyone or commit it to GitHub.

Save the Key on Your Mac

On your Mac, create a file for the SSH key:

bash
nano ~/.ssh/contabo-deploy

Paste the private key.

Save the file and then run:

bash
chmod 600 ~/.ssh/contabo-deploy

Now let’s make connecting to the server easier.

Open your SSH config:

bash
nano ~/.ssh/config

Add:

bash
Host myserver HostName YOUR_SERVER_IP User deploy IdentityFile ~/.ssh/contabo-deploy

Now instead of typing the full SSH command, we can simply run:

bash
ssh myserver

If you successfully get into the server asdeploy, we're good to go.

3. Create the Server Setup Script

Now let’s prepare the server.

We could install everything manually, but I prefer using a small script.

That way, if I need to set up another server later, I don’t have to remember every single command.

Create the script:

bash
nano setup-server.sh

Paste the following:

bash
#!/bin/bash set -e
bash
echo "[1/5] Updating system packages..."apt-get update apt-get upgrade -y echo "[2/5] Installing essentials..."apt-get install -y \ curl \ git \ ufw \ nginx \ certbot \ python3-certbot-nginx \ fail2ban echo "[3/5] Installing Docker..."if ! command -v docker>/dev/null 2>&1;
then curl -fsSL https://get.docker.com | sh systemctl enable docker systemctl start docker else echo "Docker already installed." fiecho "Docker version:" docker --versionecho "Docker Compose version:" docker compose version echo "[4/5] Configuring firewall..."ufw default deny incoming ufw default allow outgoingufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcpufw --force enableecho "Firewall status:" ufw status echo "[5/5] Creating application directory..."mkdir -p /home/deploy/appschown -R deploy:deploy /home/deploy/apps echo "" echo "==============================" echo " Server setup complete!" echo "=============================="

Save the file.

Then make it executable:

bash
chmod 700 setup-server.sh

Run the script:

bash
sudo bash setup-server.sh

Now we wait.

The script is going to update the server, install the tools we need, install Docker, configure UFW, install Fail2Ban, and create our application directory.

This saves us from manually running all those commands every time.

4. A Quick Note About the Firewall

UFW

The script also sets upUFW, which is our firewall.

For now, we’re allowing three ports:

bash
22 → SSH 80 → HTTP 443 → HTTPS

Everything else coming into the server is denied by default.

Fail2Ban

We also installedFail2Ban, which helps protect services such as SSH from repeated brute-force login attempts.

You can check the firewall with:

bash
sudo ufw status

You should see something similar to:

bash
22/tcp ALLOW 80/tcp ALLOW 443/tcp ALLOW

5. Pull the Application From GitHub

Now that the server is ready, let’s bring our application in.

For me, I normally pull the application from GitHub.

First, switch to our deployment user:

bash
su - deploy

Go to the application directory:

bash
cd ~/apps

Now generate an SSH key for GitHub:

bash
ssh-keygen -t ed25519 -C "your-email@example.com"

Once it’s created, display the public key:

bash
cat ~/.ssh/id_ed25519.pub

Copy the output.

Go to GitHub and add the key under:

Settings → SSH and GPG keys

Settings → SSH and GPG keys

Make sure you’re adding it to your GitHub account, not the repository settings.

You can test the connection:

bash
ssh -T git@github.com

If GitHub recognizes you, we’re ready.

Now clone the application:

bash
cd ~/apps
bash
git clone git@github.com:USERNAME/REPOSITORY.git

Then move into the project:

bash
cd REPOSITORY

And there we go.

Our application is now on the server.

6. Set Up the Environment

Before starting Docker, we need to configure our production environment.

For example:

bash
nano .env

Add the environment variables your application needs.

This could include things like:

bash
DATABASE_URL=... SECRET_KEY=... API_KEY=... #then after chmod 600 .env

Make sure you don’t accidentally commit your production.envfile to GitHub.

This is where you’ll also configure your database, storage, email service, API keys, and any other production services your application depends on.

7. Deploy Docker

Now we’re finally ready to deploy.

From inside your application directory, run:

bash
docker compose -f docker-compose.prod.yml up -d --build

Docker will build the images and start the containers.

Once that’s finished, let’s check what’s running:

bash
docker compose -f docker-compose.prod.yml ps

You can also check your images:

bash
docker images

If something isn’t working, don’t panic.

The first thing I normally check is the logs:

bash
docker logs CONTAINER_NAME

Or, since we’re using Docker Compose:

bash
docker compose -f docker-compose.prod.yml logs

For a specific service:

bash
docker compose -f docker-compose.prod.yml logs SERVICE_NAME

Take a moment here and make sure the containers are actually running before moving on.

8. Configure Nginx

Now our application is running inside Docker.

But we don’t want users accessing the application directly through something like:

bash
http://YOUR_SERVER_IP:8000

Instead, we’ll put Nginx in front of it.

Create an Nginx configuration file:

bash
sudo nano /etc/nginx/sites-available/myapp

Add:

bash
server { listen 80;
bash
server_name yourdomain.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; } 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;
  proxy_read_timeout 120s;
  proxy_connect_timeout 10s;
}
}

Replace:

bash
yourdomain.com

with your actual domain.

Also, make sure8000matches the port your Docker application is exposing.

Now enable the configuration:

bash
sudo ln -s /etc/nginx/sites-available/myapp \ /etc/nginx/sites-enabled/myapp

Create the Certbot directory:

bash
sudo mkdir -p /var/www/certbot

Before reloading Nginx, let’s test the configuration:

bash
sudo nginx -t

If you get:

bash
syntax is ok test is successful

then we’re good.

Reload Nginx:

bash
sudo systemctl reload nginx

9. Point Your Domain to the Server

Now let’s connect our domain.

For this example, I’m using Cloudflare.

A record

Create anA recordthat points your domain to the Contabo VPS IP.

For example:

bash
Type: A Name: api IPv4 address: YOUR_SERVER_IP

So:

bash
api.myapp.com → YOUR_SERVER_IP

Once DNS has propagated, test the domain:

bash
curl -I http://api.myapp.com

You can also test the server directly:

bash
curl -s -o /dev/null -w "%{http_code}" http://YOUR_SERVER_IP/

Depending on your application, you might get200,301,404, or another expected response.

The important thing is that we’re actually reaching the server.

10. Generate the SSL Certificate

Now let’s get HTTPS working.

We’ll use Certbot for this.

Run:

bash
sudo certbot --nginx -d api.myapp.com

Replaceapi.myapp.comwith your actual domain.

If everything is configured correctly, Certbot should generate the certificate and update Nginx for us.

Once it finishes, test HTTPS:

bash
curl -I https://api.myapp.com

You should get a successful response.

For example:

bash
HTTP/2 200

You can also open the domain in your browser.

If you see your application with the padlock 🔒, we’re doing well.

11. Disable Root SSH Access

Remember at the beginning when we logged in as root?

Now that our deployment user is working, we don’t need to leave root SSH access enabled.

Before doing this, make absolutely sure you can log in using your deployment user.

Before doing this, make absolutely sure you can log in using your deployment user.

From your local machine, test:

bash
ssh myserver

If that works, we’re safe to continue.

Open the SSH configuration:

bash
sudo nano /etc/ssh/sshd_config

Make sure these settings are present:

bash
PermitRootLogin no PasswordAuthentication no

This means root won’t be able to log in through SSH, and password-based SSH authentication will be disabled.

You can also apply the changes using:

bash
sudo nano /etc/ssh/sshd_config.d/99-hardening.conf # then add PermitRootLogin no PasswordAuthentication no PubkeyAuthentication yes # or one line though not advised sudo sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
bash
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config

Now let’s rate-limit SSH connections using UFW:

bash
sudo ufw delete allow 22/tcp sudo ufw limit 22/tcp

Restart SSH:

bash
sudo systemctl restart ssh

Finally, let’s make sure Fail2Ban is running:

bash
sudo systemctl status fail2ban

That’s All

And that’s it.

We started with a fresh Contabo VPS and ended up with:

  • Docker running our application

  • A dedicated deployment user

  • SSH key authentication

  • Root SSH access disabled

  • UFW configured

  • Fail2Ban running

  • Nginx configured

  • Our domain pointing to the VPS

  • HTTPS enabled

  • Our application running in production

There are still a few things we can improve from here — automated deployments, backups, monitoring, Docker health checks, Cloudflare configuration, database backups, and maybe even zero-downtime deployments.

You can leave a comment on how we can do it better.