Marcio Cunha

Docker Compose on Ubuntu Server: Running Multiple Services Efficiently

Learn how to architect, deploy, and manage multi-container systems using Docker Compose in a production Ubuntu Server environment with secure persistence and isolated networks.

Marcio Cunha12 min
Also available in:EspañolPortuguês
Summary
  • Declarative YAML files replace dozens of manual terminal commands and eliminate discrepancies between environments.
  • Custom virtual networks isolate internal database traffic while exposing only necessary ports to the outside world.
  • Persistent volumes save vital data outside the ephemeral container lifecycle to prevent catastrophic loss during failures.
  • External environment files protect sensitive credentials against accidental exposure in open-source code repositories.
  • Automatic restart policies keep infrastructure resilient after power outages or operating system reboots.

Why Replace Manual Commands with Declarative Orchestration

When we first start deploying applications using Docker, the process is usually straightforward. We run a command like 'docker run' in the terminal, map the ports, configure the variables, and the service comes to life. In practice, this works very well for quick tests. However, as our system grows and we need to combine an API, a database, a caching system, and an admin dashboard, the terminal turns into an endless list of complex commands that no one can memorize accurately. This is precisely the scenario where Docker Compose becomes an indispensable tool.

Simply put, Docker Compose is a conductor that reads a score written in a simple text file called 'docker-compose.yml' and orchestrates our entire application. Instead of running five separate commands, you write a single manifest where you describe which services make up the system, how they connect, which ports open to the world, and where they store their files. When you type the command to start, the ecosystem does all the heavy lifting behind the scenes, ensuring everything boots up in the correct order with communication properly established.

To set up this structure, the first step is preparing the ground on a robust operating system. Ubuntu Server is the industry standard for infrastructure environments, whether on a physical machine in your home or in cloud servers. Before installing anything, we must ensure the system is updated. We run basic Ubuntu package update commands and then install the official Docker engine along with the Compose plugin. This solid foundation ensures containers have direct access to the operating system kernel without performance bottlenecks.

Designing Service Architecture in the YAML File

The heart of any project managed by Docker Compose is the configuration file, structured in the YAML language. YAML is a data serialization format designed to be easily readable by humans, using indentation spaces to define hierarchies. In practice, it looks like an outline organized into topics. When planning multiple services, we need to think about application topology before writing the first line of code, clearly separating what is executable code, what is storage, and what is networking.

Let us imagine a typical architecture composed of three essential pillars: a web application developed in Node.js, a PostgreSQL database to store persistent records, and a Redis cache tool to speed up repetitive queries. In the YAML file, each of these elements gets its own section called a 'service'. The web service needs to know it depends on the database to start, preventing connection errors if the application tries to talk to a database that has not finished booting. This native dependency management is one of the biggest gains in operational sanity the tool offers.

Below is a functional example of how this structure looks in practice, ready to be copied and adapted for your own server project:

version: '3.8'services:  web:    build: .    container_name: web_api_app    restart: always    ports:      - '8080:80'    environment:      - DB_HOST=postgres_db      - DB_USER=admin      - DB_PASS=secure_password_here    depends_on:      - db    networks:      - internal_net  db:    image: postgres:15-alpine    container_name: postgres_db    restart: always    environment:      - POSTGRES_DB=mydb      - POSTGRES_USER=admin      - POSTGRES_PASSWORD=secure_password_here    volumes:      - pgdata:/var/lib/postgresql/data    networks:      - internal_net  cache:    image: redis:alpine    container_name: redis_cache    restart: always    networks:      - internal_netvolumes:  pgdata:networks:  internal_net:    driver: bridge

Analyzing the code above, we notice fundamental architectural decisions that keep the environment secure and organized. The parameter 'restart: always' ensures that if Ubuntu Server restarts or a process fails due to lack of memory, Docker will automatically restart it. Another critical point is the use of named volumes in PostgreSQL. By default, containers are ephemeral, meaning everything generated inside them disappears when the container is destroyed. The 'pgdata' volume isolates physical database data in a secure directory on the Ubuntu Server disk, ensuring you do not lose vital info when updating the application image.

Network Isolation and Internal Communication

Managing multiple services on a single machine brings a natural challenge of security and traffic organization. If we leave all containers talking openly or exposing unnecessary ports outside the server, we drastically increase the attack surface for intrusions. This is where the concept of isolated virtual networks in Docker comes in. A virtual network works like a logical VLAN or a gated community, where only authorized residents can move around freely, while outsiders only enter through the main gate.

In the YAML example we built, we created a network called 'internal_net' using the default 'bridge' driver. In practice, this means our web application can talk directly to the database using the service name 'postgres_db' as if it were an IP address or a traditional domain name. Docker has a built-in internal DNS server that automatically translates these names into the actual IP addresses of the containers. The most important detail is that the database and cache do not expose any ports to the external network of your home or business; only the web service opens port 8080 to the world.

This separation ensures that even if someone discovers a flaw in your web API, the database remains protected behind an invisible wall on the internal network. Operationally, this drastically simplifies firewall configuration on Ubuntu Server, since the UFW (Uncomplicated Firewall) command only needs to allow standard HTTP and HTTPS traffic, leaving internal database traffic entirely under the governance of the container engine.

Managing Lifecycle and Monitoring Container Health

With the configuration file ready and saved on the server, interacting with the system becomes a surprisingly clean and predictable task. Instead of managing processes scattered across the operating system, we use simple Compose commands to control the entire fleet of services simultaneously. The command 'docker compose up -d' instructs the engine to download necessary images, create networks, mount volumes, and start all services in the background in detached mode, freeing the terminal for new instructions.

To track application behavior in real-time, the command 'docker compose logs -f' displays a unified stream of all messages printed by different services, coloring the output to make it easier to identify which container generated each log line. If we need to update the web application because a new version of the code was released, the process boils down to updating the source code on the server and running 'docker compose up -d --build'. The system rebuilds only what changed, gracefully shuts down the old version, and brings the new version online with minimal interruption.

Daily maintenance also benefits immensely from this centralized approach. When we need to stop the environment to free up resources or perform preventive hardware maintenance on the Ubuntu Server, the command 'docker compose down' shuts down processes and cleans up temporary networks cleanly. If we want to remove everything including data volumes for a complete reset, we can use additional flags, though this is an operation requiring extreme care to avoid irreversible data loss in production environments.

Security Best Practices and Final Considerations

Running multiple services via Docker Compose on Ubuntu Server transforms how we manage workloads, unifying development simplicity with production robustness. However, ease of use must not make room for security negligence. Never put database passwords in plain text directly inside the 'docker-compose.yml' file. The ideal approach is to use separate environment variable files with the '.env' extension and keep them out of Git version control, ensuring sensitive credentials remain restricted to your server's secure disk.

In addition, monitor resource consumption using native Linux tools alongside Docker statistics. Misconfigured containers can consume all available RAM and cause widespread crashes on the host operating system. Defining clear CPU and memory limits on each service inside the YAML file adds an extra layer of defense against cascading failures. By mastering orchestration with Docker Compose, you gain autonomy, predictability, and the ability to scale your projects with absolute confidence and operational control.