...

How to Deploy a Docker Compose Application on a VPS with Nginx and SSL

Martin Klein

Reading time 1 minute

In this guide, we will deploy a small web application on a VPS running Ubuntu 24.04 using Docker Compose. The application will run inside a container, while Nginx will accept external HTTP and HTTPS requests and forward them to the container’s local port.

The final architecture will look like this: user → HTTPS → Nginx → Docker container → application

For deployment, you will need a VPS with a public IP address, a domain or subdomain, and open TCP ports 22, 80, and 443. For a small test application, a configuration with 2 vCPUs, 2–4 GB of RAM, and at least 20 GB of disk space is sufficient.

First, we will create a virtual machine and connect to it over SSH. Then we will install Docker Engine and the Docker Compose Plugin, prepare the application files, Dockerfile, .dockerignore, environment variables, and the compose.yaml configuration.

After starting the container, we will check its status and logs, and then configure Nginx as a reverse proxy. In this setup, the application will listen only on a local VPS port, while externally only Nginx on the standard web ports will be exposed.

In our example, Nginx and Certbot will be installed directly on the host, outside Docker, to demonstrate a hybrid deployment approach, or installation on separate VPS instances.

The test environment will use the following subdomain: docker.deploy-test-lab.com

After creating the DNS record, we will issue a Let’s Encrypt SSL certificate and configure automatic redirection from HTTP to HTTPS. We will also cover the application update process: obtaining the new version of the files, rebuilding the image, and restarting the containers with Docker Compose.

As a result, we will have a working containerized application available via HTTPS under a domain name, with Docker and the containers starting automatically after the VPS is rebooted.

Application Architecture with Docker Compose

Before deployment, let’s review which components will run on the VPS and how user requests will reach the application container.

Required components

This guide uses the following components:

ComponentPurpose
Ubuntu 24.04 LTSVirtual machine operating system
Docker EngineRuns and isolates containers
Docker ComposeManages the application using the compose.yaml file
DockerfileDefines how the application image is built
NginxHandles external HTTP and HTTPS requests and terminates SSL
CertbotIssues and renews a Let’s Encrypt SSL certificate
DNSMaps the subdomain to the virtual machine’s public IP address

As an example, we will deploy a small Node.js web application. It will run inside a container and listen on local port 3000.

For a test project, a VPS with 2 vCPUs, 2–4 GB of RAM, and at least 20 GB of disk space is sufficient. The server also needs a public IP address and inbound TCP ports 22, 80, and 443.

How Docker Compose, the application, and Nginx interact

Docker Engine creates an isolated environment for the application. The container includes the required Node.js version, dependencies, and project source files. This ensures that the application does not depend on libraries installed globally on the VPS.

Docker Compose reads the configuration from the compose.yaml file. This configuration specifies the build parameters, service name, ports, environment variables, networks, and the container’s automatic restart policy.

The application will be available only through the VPS’s local interface: 127.0.0.1:3000

This address cannot be accessed directly from the internet. All external requests are first received by Nginx, which then forwards them to the application as a reverse proxy.

Nginx also handles the domain, redirects from HTTP to HTTPS, and manages the SSL certificate. As a result, visitors interact only with Nginx, while the internal structure of the Dockerized application remains hidden.

Preparing the virtual machine

First, create a clean virtual machine running Ubuntu 24.04 LTS, attach a public IP address to it, and verify the network rules. The process is almost the same as preparing a VPS for a standard application, but the project itself will later run in a container.

Creating a VM in the cloud control panel

Create a new virtual machine in the cloud control panel. For the test application, you can use the following configuration:

  • VM name — docker-compose-guide;
  • image — Ubuntu 24.04 LTS;
  • 2 vCPU;
  • 4 GB of RAM;
  • 20 GB system disk;
  • project private network;
  • SSH key pair authentication.

When creating a new SSH key pair, download the private key and save it on your computer. You will need it to connect to the server.

After confirming the configuration, wait until the virtual machine reaches the Active status.

Attaching a Public IP Address and Configuring the Firewall

Initially, the virtual machine receives a private IP address within the cloud network. To connect to the VM over SSH, use a domain, and make the application accessible from the internet, you need to attach a Floating IP to the VM.

This guide will reuse the following public address: 203.0.113.10

In your own configuration, use the Floating IP assigned to your virtual machine.

In the security group, allow inbound TCP connections:

PortPurpose
22SSH connection to the VPS
80HTTP and Let’s Encrypt domain validation
443Secure HTTPS connections

You do not need to expose application port 3000 to the internet. Later, Docker will publish it only on the local interface 127.0.0.1, while Nginx will handle external requests.

Connecting to a VPS via SSH

On current versions of Windows, you can connect using PowerShell, Command Prompt, or Windows Terminal. Navigate to the directory containing your private SSH key and run: ssh -i .\docker-compose-guide.pem ubuntu@<FLOATING_IP>

Replace <FLOATING_IP> with the public address of your virtual machine. In our case, the command looks like this: ssh -i .\docker-compose-guide.pem [email protected]

The key file name may also be different. Replace docker-compose-guide.pem with the name of your own private key or the full path to it.

On the first connection, SSH will ask you to confirm the server fingerprint. Enter: yes

If this Floating IP was previously used by another virtual machine, SSH may report that the remote host key has changed. You can remove the old entry with the following command: ssh-keygen -R 203.0.113.10

Then connect again and confirm the new fingerprint. You should remove the saved key only if the change is expected—for example, after deleting the old VM and assigning the same IP address to a new server.

Updating Ubuntu

After connecting, update the package index and installed packages:

sudo apt update

sudo apt upgrade -y

Next, install the basic utilities needed to add the Docker repository and continue configuring the server: sudo apt install -y ca-certificates curl gnupg unzip ufw

Check the operating system version: cat /etc/os-release

The output should indicate Ubuntu 24.04 LTS. After the update, the virtual machine is ready to install Docker Engine and Docker Compose.

Installing Docker and Docker Compose

Adding the Official Docker Repository

Docker can be installed from Ubuntu’s standard repositories, but for a current version of Docker Engine and the Compose Plugin, use Docker’s official APT repository.

First, create a directory for repository keys: sudo install -m 0755 -d /etc/apt/keyrings

Download Docker’s official GPG key:

sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \

-o /etc/apt/keyrings/docker.asc

Make the key readable: sudo chmod a+r /etc/apt/keyrings/docker.asc

Add the Docker repository:

echo \

&quot;deb [arch=$(dpkg –print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \

$(. /etc/os-release && echo &quot;${UBUNTU_CODENAME:-$VERSION_CODENAME}&quot;) stable&quot; \

| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Then update the package index: sudo apt update

This installation method lets you obtain Docker Engine and related components directly from Docker’s official repository.

Installing Docker Engine and the Compose Plugin

Install Docker Engine, the command-line interface, containerd, Buildx, and the Docker Compose Plugin:

sudo apt install -y \

docker-ce \

docker-ce-cli \

containerd.io \

docker-buildx-plugin \

docker-compose-plugin

The docker-compose-plugin package provides the modern command: docker compose

It is used without a hyphen between docker and compose. The separate docker-compose command refers to the legacy standalone version, while the Compose Plugin is recommended for new installations.

After installation, Docker usually starts automatically. Check its status: sudo systemctl status docker –no-pager

Verifying installed versions

Check the Docker Engine version: docker –version

Then check Docker Compose: docker compose version

Make sure Docker responds to commands: sudo docker run –rm hello-world

This command will pull a small test image, run a container, and remove it after it exits.

Configuring Docker to Start Automatically

On Ubuntu, the Docker service is usually enabled to start automatically during installation. Additionally, explicitly enable and start it: sudo systemctl enable –now docker

Check the statuses:

systemctl is-active docker

systemctl is-enabled docker

Expected result:

active

enabled

On Ubuntu and Debian, the Docker service starts with the system after a standard installation. However, explicitly checking systemctl is-enabled lets you confirm that the VPS is configured correctly.

By default, a regular user may not always have access to the Docker socket, so you can use sudo docker in subsequent commands. For a test environment, you can also add the ubuntu user to the docker group: sudo usermod -aG docker ubuntu

For the group membership to take effect, end the SSH session and reconnect: exit

After logging in again, check: docker ps

If the command runs without an access error, you can run subsequent operations without sudo.

Preparing the Application

As an example, we will create a small Node.js application. It will return a web page indicating that the container has been successfully started with Docker Compose.

Creating the project directory

Create the application directory: sudo mkdir -p /opt/docker-compose-app

Make the current user the owner: sudo chown -R $USER:$USER /opt/docker-compose-app

Change to the directory: cd /opt/docker-compose-app

All application files, the Dockerfile, and the Compose configuration will be stored here.

Adding the application files

Create the package.json file: nano package.json

Add:

{

&quot;name&quot;: &quot;docker-compose-guide&quot;,

&quot;version&quot;: &quot;1.0.0&quot;,

&quot;private&quot;: true,

&quot;description&quot;: &quot;Test application deployed with Docker Compose&quot;,

&quot;main&quot;: &quot;app.js&quot;,

&quot;scripts&quot;: {

&quot;start&quot;: &quot;node app.js&quot;

},

&quot;dependencies&quot;: {

&quot;express&quot;: &quot;^5.1.0&quot;

}

}

Save the file: Ctrl+O → Enter → Ctrl+X

Now create the main application file: nano app.js

Paste the following code:

const express = require(‘express’);

const app = express();

const port = Number.parseInt(process.env.PORT || ‘3000’, 10);

const appName = process.env.APP_NAME || ‘Docker Compose App’;

app.get(‘/’, (request, response) => {

response.type(‘html’).send(`

<!doctype html>

<html lang=&quot;en&quot;>

<head>

<meta charset=&quot;utf-8&quot;>

<meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;>

<title>${appName}</title>

<style>

body {

margin: 0;

min-height: 100vh;

display: grid;

place-items: center;

font-family: Arial, sans-serif;

background: #f4f6f8;

color: #17202a;

}

main {

width: min(680px, calc(100% – 48px));

padding: 48px;

border-radius: 20px;

background: #ffffff;

box-shadow: 0 18px 50px rgba(0, 0, 0, 0.08);

}

code {

padding: 3px 7px;

border-radius: 6px;

background: #eef1f4;

}

</style>

</head>

<body>

<main>

<h1>${appName}</h1>

<p>The application is running successfully in a Docker container.</p>

<p>Traffic is routed through <code>Nginx</code> over a secure connection.</p>

</main>

</body>

</html>

`);

});

app.get(‘/health’, (request, response) => {

response.json({

status: ‘ok’,

service: appName

});

});

app.listen(port, ‘0.0.0.0’, () => {

console.log(`${appName} is listening on port ${port}`);

});

The application listens on 0.0.0.0 inside the container. The port itself will later be published only on the VPS’s local interface via compose.yaml.

The /health route will be used for a quick application health check: http://127.0.0.1:3000/health

Creating a Dockerfile

Create a Dockerfile file: nano Dockerfile

Add the following:

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./

RUN npm install –omit=dev

COPY . .

ENV NODE_ENV=production

EXPOSE 3000

CMD [&quot;npm&quot;, &quot;start&quot;]

The official Node.js image is used as the base image. Docker recommends using trusted official images and excluding unnecessary files from the build context.

Dockerfile instructions are executed sequentially:

  • FROM selects the base image;
  • WORKDIR creates the working directory in the container;
  • COPY copies the project files;
  • RUN installs dependencies;
  • EXPOSE documents the application port;
  • CMD sets the startup command.

Next, we will create a .dockerignore file.

Creating a .dockerignore file

Create the file: nano .dockerignore

Add:

node_modules

npm-debug.log

.git

.gitignore

.env

Dockerfile*

compose*.yaml

README.md

The .dockerignore file excludes unnecessary data from the build context. This reduces the amount of data Docker transfers and prevents local dependencies, Git history, and environment variables from being accidentally included in the image.

Configuring environment variables

Create the .env file: nano .env

Add:

APP_NAME=Deploy Test Lab

APP_PORT=3000

The APP_NAME variable will be passed into the container, and APP_PORT will be needed in the Docker Compose configuration.

Restrict access to the file for other users on the VPS: chmod 600 .env

Do not include .env in the Docker image or a public repository. In a production project, this file may store passwords, tokens, and other environment parameters.

Check the directory structure: ls -la

At this stage, it should contain:

.dockerignore

.env

Dockerfile

app.js

package.json

The application files are ready. The next step is to create compose.yaml, configure the local port, and start the container.

Creating a Docker Compose Configuration

Defining services in compose.yaml

Create a compose.yaml file in the project root:

cd /opt/docker-compose-app

nano compose.yaml

Add the following configuration:

services:

app:

build:

context: .

dockerfile: Dockerfile

container_name: docker-compose-guide

restart: unless-stopped

env_file:

– .env

environment:

PORT: 3000

ports:

– &quot;127.0.0.1:${APP_PORT}:3000&quot;

networks:

– app-network

networks:

app-network:

driver: bridge

The top-level services section defines the application containers. In this case, a single service, app, is used and built from the local Dockerfile. Docker Compose lets you define services, networks, ports, and other settings in a single configuration file.

The setting:

build:

context: .

dockerfile: Dockerfile

tells Docker to use the current directory as the build context and look for the Dockerfile in it.

The .env file is included using:

env_file:

– .env

The APP_NAME variable from this file will be passed into the container. The additional PORT variable defines the port that the application listens on inside the container.

Configuring ports, networking, and the restart policy

The port binding looks like this: 127.0.0.1:${APP_PORT}:3000

Here:

  • 127.0.0.1 restricts access to the VPS loopback interface;
  • ${APP_PORT} is substituted from the .env file;
  • 3000 is the application port inside the container.

As a result, the application will be available on the server at: http://127.0.0.1:3000

but this port will not be accessible directly from the internet. Later, Nginx will forward external requests to it.

A restart policy is also set for the service: restart: unless-stopped

It restarts the container after a failure and when Docker starts, but does not bring it back up if the container was stopped manually. Docker Compose supports the no, always, on-failure, and unless-stopped policies.

A separate bridge network:

networks:

– app-network

Creates an isolated network for the project. Currently, one container runs in it, but later you can add a database, Redis, or another internal service to the configuration.

Checking the Docker Compose configuration

Save the file: Ctrl+O → Enter → Ctrl+X

Check the resulting configuration: docker compose config

The command processes compose.yaml, substitutes variables from .env, and outputs the normalized configuration. If the YAML contains an indentation error, an unknown parameter, or a missing required value, Docker Compose will report it.

For a shorter check without printing the entire configuration, run: docker compose config –quiet

If there are no errors, the command will exit without any messages. Then print the resulting service configuration: docker compose config

Running the Application with Docker Compose

Building and Running Containers

From the project directory, build the image and start the container in detached mode: docker compose up -d –build

The –build option forces the image to be built before startup, while -d keeps the container running in the background. The docker compose up command creates and starts the services and, when the configuration or image changes, recreates the associated containers.

During the first build, Docker will:

  1. Pull the base image node:22-alpine;
  2. Copy package.json;
  3. Install dependencies;
  4. Add the application files;
  5. Create the container;
  6. Attach it to the app-network network.

When the command completes, the service should show a running status with no errors.

Checking container status

View the project status: docker compose ps

The expected output should look similar to the following:

NAMEIMAGECOMMANDSERVICESTATUSPORTS
docker-compose-guidedocker-compose-app-app&quot;npm start&quot;appUp 10 seconds127.0.0.1:3000->3000/tcp

Make sure that:

  • The container status is Up;
  • The service is named app;
  • The port is published as 127.0.0.1:3000->3000/tcp.

If necessary, you can list all running containers: docker ps

The docker compose ps command shows the containers that belong to the current Compose project.

Viewing application logs

View the most recent log lines: docker compose logs –tail=50 app

The output should include the message: Deploy Test Lab is listening on port 3000

To view logs in real time, use: docker compose logs -f app

The -f option keeps outputting new log lines as they appear. To stop viewing the logs without stopping the container, press Ctrl+C.

If the container keeps restarting or enters the Exited state, the logs usually show the cause: a JavaScript error, a missing dependency, an incorrect environment variable, or a port that is already in use.

Testing the application on the local port

Check the home page directly on the VPS: curl -I http://127.0.0.1:3000

Expected response: HTTP/1.1 200 OK

Then check the health endpoint: curl http://127.0.0.1:3000/health

The application should return JSON: {&quot;status&quot;:&quot;ok&quot;,&quot;service&quot;:&quot;Deploy Test Lab&quot;}

Additionally, verify that the port is listening only on the local interface: sudo ss -lntp | grep :3000

The output should show 127.0.0.1:3000, not 0.0.0.0:3000.

This confirms that the container is not directly accessible from the Internet. In the next step, we will configure Nginx as a reverse proxy and route external traffic to the application’s local port.

Configuring Nginx as a reverse proxy

Installing Nginx

Install Nginx from the standard Ubuntu repository: sudo apt install -y nginx

Enable automatic startup and start the service immediately: sudo systemctl enable –now nginx

Check its status:

systemctl is-active nginx

systemctl is-enabled nginx

Expected result:

active

enabled

Nginx will run on the host system, receive external requests, and forward them to the application exposed by the container at the local address 127.0.0.1:3000. Nginx officially supports operation as an HTTP reverse proxy.

Creating a server block

Create a separate configuration file: sudo nano /etc/nginx/sites-available/docker-compose-app

Add the following configuration:

server {

listen 80;

listen [::]:80;

server_name docker.deploy-test-lab.com;

access_log /var/log/nginx/docker-compose-app_access.log;

error_log /var/log/nginx/docker-compose-app_error.log;

location / {

proxy_pass http://127.0.0.1:3000;

proxy_http_version 1.1;

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;

}

}

The server_name directive specifies the application’s domain or subdomain. In this case, we use: docker.deploy-test-lab.com

If you use a different domain, replace it in this file, in the DNS records, and in the subsequent Certbot command.

Save the file: Ctrl+O → Enter → Ctrl+X

Enable the server block:

sudo ln -s /etc/nginx/sites-available/docker-compose-app \

/etc/nginx/sites-enabled/docker-compose-app

Disable the default Nginx configuration: sudo rm -f /etc/nginx/sites-enabled/default

Forwarding requests to the Docker container

The main request forwarding is handled by this directive: proxy_pass http://127.0.0.1:3000;

It routes requests from the location / block to the application’s local port. Because Docker Compose published the port only on 127.0.0.1, the container is not directly accessible from the internet. Only Nginx connects to it. The behavior of proxy_pass and request forwarding to the proxied server are described in the official Nginx module documentation.

The Host header preserves the domain name requested by the user: proxy_set_header Host $host;

The remaining headers pass information about the original connection to the application:

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;

This allows the application to obtain the user’s IP address and determine whether HTTP or HTTPS was used.

Before testing Nginx, make sure the container responds locally: curl http://127.0.0.1:3000/health

Expected response: {“status”:”ok”,”service”:”Deploy Test Lab”}.

Checking the Nginx configuration

Check the syntax: sudo nginx -t

If the configuration is valid, you will see the following output:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok

nginx: configuration file /etc/nginx/nginx.conf test is successful

Apply the changes without stopping the web server: sudo systemctl reload nginx

Then check the server block locally:

curl -I \

-H &quot;Host: docker.deploy-test-lab.com&quot; \

http://127.0.0.1

The response should include the following status code: HTTP/1.1 200 OK

You can also check the health endpoint through Nginx:

curl \

-H &quot;Host: docker.deploy-test-lab.com&quot; \

http://127.0.0.1/health

If Nginx returns the application’s JSON response, the reverse proxy is working correctly.

Connecting a Domain and SSL

Creating a DNS Record

In the domain’s DNS zone, create an A record with the following parameters:

ParameterValue
TypeA
Namedocker
IPv4 address203.0.113.10
TTLAuto or the default value

The address 203.0.113.10 is used in our test environment. In your configuration, specify the Floating IP assigned to your virtual machine.

If DNS is managed through Cloudflare, you can use DNS only mode for the initial setup so that the domain points directly to the VPS.

After saving the record, check DNS from your local computer: nslookup docker.deploy-test-lab.com

The response should show the VPS IP address: 203.0.113.10

Also check the application over HTTP: http://docker.deploy-test-lab.com

Before the certificate is issued, it should be accessible without HTTPS.

You can also verify it from the terminal: curl -I http://docker.deploy-test-lab.com

Do not proceed to Certbot until the domain returns the correct IP address and the application is available over HTTP.

Issuing a Let’s Encrypt Certificate

Install Certbot via Snap: sudo snap install –classic certbot

Create a symlink to the executable: sudo ln -s /snap/bin/certbot /usr/local/bin/certbot

If the symlink already exists, you do not need to create it again.

Request a certificate for the subdomain:

sudo certbot –nginx \

-d docker.deploy-test-lab.com

When it runs, Certbot will prompt you to:

  • Specify an email address;
  • Accept the terms of use;
  • Choose whether to join the mailing list;
  • Confirm the Nginx configuration changes.

The Nginx plugin can automatically obtain the certificate, add HTTPS settings, and apply them to the existing HTTP site.

For HTTP-01 domain validation, the server must be accessible externally on port 80. Let’s Encrypt recommends keeping this port open and redirecting regular HTTP requests to HTTPS.

After the certificate has been issued successfully, check Nginx again: sudo nginx -t

Then run a dry run of automatic renewal: sudo certbot renew –dry-run

If the check completes successfully, the certificate can be renewed automatically.

Configuring HTTP-to-HTTPS redirection

After Certbot runs, the Nginx configuration will include a separate HTTPS block and a redirect rule for port 80.

Check the HTTP URL: curl -I http://docker.deploy-test-lab.com

Expected response:

HTTP/1.1 301 Moved Permanently

Location: https://docker.deploy-test-lab.com/

This means that unencrypted requests are automatically redirected to the secure version of the site.

Do not remove the inbound rule for TCP port 80. It is required for HTTP redirection and may be used for future domain ownership checks.

Verifying the secure connection

Open the application in a browser: https://docker.deploy-test-lab.com

The test application page should load with the following message:

The application has been successfully started in a Docker container.

Check HTTPS from the terminal: curl -I https://docker.deploy-test-lab.com

The server should return a successful response: HTTP/2 200

Check the health endpoint: curl https://docker.deploy-test-lab.com/health

Expected result: {&quot;status&quot;:&quot;ok&quot;,&quot;service&quot;:&quot;Deploy Test Lab&quot;}

At this stage, external traffic passes over HTTPS through Nginx, while the application itself continues to run inside the Docker container on a local port of the VPS.

Updating the application

Obtaining the Latest Version of the Files

The update method depends on how the source code is delivered to the VPS. If the project is stored in a Git repository, go to the application directory and pull the latest version:

cd /opt/docker-compose-app

git pull

Before updating, make sure the directory does not contain any uncommitted local changes: git status

If the project is delivered as an archive or as individual files, first replace the source code, package.json, Dockerfile, and any other changed items, but do not delete .env or the application’s persistent data.

After updating, validate the Compose configuration: docker compose config –quiet

No output means that the compose.yaml file was processed successfully.

Rebuilding containers

If the source code, dependencies, or Dockerfile have changed, rebuild the image and start the updated container: docker compose up -d –build

Compose will compare the current configuration with the project that is already running and recreate the container if necessary. For our application, this is sufficient: the image is built from the local directory, and the service is started in the background.

After it finishes, check the status: docker compose ps

And the last log lines: docker compose logs –tail=50 app

If compose.yaml uses prebuilt images from a registry, you can run the following before starting the new version:

docker compose pull

docker compose up -d

The docker compose pull command downloads the latest images specified for the services.

Restarting Without Deleting Persistent Data

A standard update does not require running: docker compose down -v

The -v option removes the project’s named and anonymous volumes, so databases, uploaded files, and other persistent data may be deleted along with them.

A safe way to update is: docker compose up -d –build

If you only need to restart an existing container without rebuilding it, use: docker compose restart app

However, docker compose restart does not apply changes made to compose.yaml. After changing the configuration, use docker compose up -d so that Compose recreates the service with the new settings.

The current demo application does not use volumes, but this principle is especially important for projects that use PostgreSQL, MySQL, Redis, or services that handle user uploads.

Cleaning Up Unused Images

After several updates, old layers and images may remain on the VPS. You can view them with the following command: docker image ls

Remove unused intermediate images: docker image prune

Docker will ask for confirmation. To run the command without an additional prompt, use: docker image prune -f

This command removes unused dangling images but does not affect images required by running containers.

A more aggressive cleanup: docker image prune -a

removes all images that are not associated with existing containers. Use it with caution on a production server, because some images will need to be downloaded or rebuilt the next time they are needed.

Post-Deployment Verification

Checking Docker Containers and the Docker Service

Go to the project directory: cd /opt/docker-compose-app

Check the Docker service status:

systemctl is-active docker

systemctl is-enabled docker

Expected result:

active

enabled

Then check the container: docker compose ps

The app service should be in the Up state, and its port should be published only on the local interface: 127.0.0.1:3000->3000/tcp

You can also check the container’s resource usage: docker stats –no-stream

The command shows CPU, RAM, and network usage at the time of the check.

Verifying Nginx and HTTPS

Check the Nginx status:

systemctl is-active nginx

systemctl is-enabled nginx

Then validate the configuration: sudo nginx -t

Make sure HTTP redirects to HTTPS: curl -I http://docker.deploy-test-lab.com

The response should include the following header: Location: https://docker.deploy-test-lab.com/

Check the secure version: curl -I https://docker.deploy-test-lab.com

Expected result: HTTP/2 200

The application health endpoint should also be available through Nginx: curl https://docker.deploy-test-lab.com/health

Expected response: {&quot;status&quot;:&quot;ok&quot;,&quot;service&quot;:&quot;Deploy Test Lab&quot;}.

Checking the application logs

View the latest container messages: docker compose logs –tail=50 app

The log should not contain restart loops, unhandled exceptions, or messages indicating that the port is already in use.

To view new events in real time, use: docker compose logs -f app

You can stop viewing the logs by pressing Ctrl+C. The container will continue running. Docker Compose provides separate commands for viewing logs and checking the status of project services.

If there are issues with the reverse proxy, also check the Nginx logs: sudo tail -n 50 /var/log/nginx/docker-compose-app_error.log

Verifying autostart after a reboot

Make sure the services are enabled to start automatically:

systemctl is-enabled docker

systemctl is-enabled nginx

The container in compose.yaml must use the following policy: restart: unless-stopped

This allows Docker to start the container automatically after the daemon or VPS restarts, provided the container was not previously stopped manually.

Reboot the server: sudo reboot

The SSH connection will be closed. Reconnect after a minute: ssh -i .\docker-compose-guide.pem [email protected]

After logging in, run:

cd /opt/docker-compose-app

printf &quot;docker: &quot;

systemctl is-active docker

printf &quot;nginx: &quot;

systemctl is-active nginx

docker compose ps

The output should show active statuses, and the application container should be in the Up state again.

Then check the health endpoint again: curl https://docker.deploy-test-lab.com/health

If the server returns JSON with the status ok, Docker, the container, Nginx, and HTTPS have successfully resumed operation after the reboot.

Final application check

Open the following URL in your browser: https://docker.deploy-test-lab.com

The page should display the Deploy Test Lab name and a message indicating that the application is running in a Docker container and that traffic is routed through Nginx.

At this point, the deployment can be considered complete. The container is started with Docker Compose, the internal port is not directly accessible from the internet, external traffic is handled by Nginx, and the connection is secured with a Let’s Encrypt certificate.

Conclusion

As a result, a web application packaged in a Docker container and managed with Docker Compose has been deployed on a VPS running Ubuntu 24.04 LTS. The project configuration is stored in compose.yaml, so the application can be started, stopped, rebuilt, and updated using standard Compose commands.

The container accepts requests only through the local address 127.0.0.1:3000 and is not directly accessible from the internet. External traffic is handled by Nginx, which acts as a reverse proxy, accepts requests for the domain name, and forwards them to the application. HTTPS is provided by a Let’s Encrypt certificate, and HTTP requests are automatically redirected to the secure version of the site.

The restart: unless-stopped policy and Docker autostart allow the container to resume operation after the VPS is rebooted. To release a new version, simply update the project files and run docker compose up -d –build again.

This setup is suitable for small web applications, APIs, administrative panels, and internal services. As the project becomes more complex, you can add a database, Redis, task queues, and other containers to compose.yaml while retaining a single management approach for the entire stack.

FAQ

How does Docker Compose differ from running a Docker container directly?

When you run a container directly, its parameters are passed in a long docker run command. Docker Compose stores services, ports, networks, environment variables, and restart policies in a compose.yaml file.

This makes the configuration easier to reuse, update, and transfer to another VPS.

Can multiple containers be defined in a single compose.yaml file?

Yes. In the services section, you can define the application, database, Redis, task queue, and other components.

Services within the same Compose network can communicate with each other using the names specified in the configuration.

Do I need to install Nginx in a separate container?

No. In this guide, Nginx is installed directly on the VPS. This approach simplifies issuing certificates with Certbot and allows you to use a single reverse proxy for multiple containerized applications.

If needed, and for portability, Nginx can also be run with Docker Compose, but this makes the certificate, port, and volume configuration more complex.

How do I update the application after changing the code?

Navigate to the project directory and run: docker compose up -d –build

Docker will rebuild the image and recreate the container with the new version of the application. After the update, check docker compose ps and the service logs.

Does docker compose down delete application data?

The docker compose down command removes the project’s containers and network, but it does not remove named volumes unless an additional option is specified.

Command: docker compose down -v

also removes volumes. For a project with a database or user files, this can result in the loss of persistent data.

What happens after the VPS is rebooted?

Docker and Nginx will start via systemd. The application container will also start automatically because of the restart policy: restart: unless-stopped

The exception is a container that was stopped manually before the reboot.

Is Docker Compose suitable for production?

Docker Compose can be used to deploy applications on a single server, including in production. For a production project, you should also configure backups, monitoring, resource limits, secure storage of secrets, and regular image updates.

Sources

  1. Docker Docs — Install Docker Engine on Ubuntu
  2. Docker Docs — Compose file reference
  3. NGINX Documentation — ngx_http_proxy_module
  4. Certbot — Nginx instructions

Subscribe to our newsletter and receive articles and news

    Check out our other materials