Skip to content
Mustafa Erbay
Tutorials · 11 min read · görüntülenme Türkçe oku

5 Ways to Quickly Run Self-Hosted Applications with Docker Compose

From minimal files to health checks, 5 effective ways I've experienced to quickly spin up self-hosted applications with Docker Compose…

100%

Recently, while prototyping a new side product on my own server, I looked for a faster way than manually spinning up services every time or dealing with complex deployment scripts. Docker Compose has always been a practical solution for me, especially for single-host environments, for quickly deploying and managing applications. In this post, I’ll share five fundamental approaches I frequently use to run your self-hosted applications more efficiently and reliably with Docker Compose.

Docker Compose is a tool for defining and running multi-container Docker applications. It allows you to configure all your application’s services, networks, and storage volumes from a single place using a YAML file. This way, you can spin up, stop, and manage applications with complex dependencies with a single command. This approach provides great convenience, especially for rapid prototyping, development environments, and small-scale production deployments.

1. Reduce Initial Setup Cost with Minimal Compose Files

When developing or deploying a self-hosted application, minimizing initial setup complexity is crucial. Often, all we need to get a service up and running is an image definition and perhaps a few port and volume settings. Trying to create a massive docker-compose.yaml file by thinking of every detail at first is both a waste of time and unnecessary fatigue. I usually start with the most basic requirements and iteratively develop the file as the application runs and needs become clearer.

For a typical web application consisting of a database, an API service, and a frontend, my initial docker-compose.yaml file usually looks like this:

version: '3.8'
services:
  database:
    image: postgres:15-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: myapp_db
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
    volumes:
      - db_data:/var/lib/postgresql/data
  api:
    build: ./api
    restart: unless-stopped
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://myuser:mypassword@database:5432/myapp_db
    depends_on:
      - database
  frontend:
    build: ./frontend
    restart: unless-stopped
    ports:
      - "3000:3000"
    depends_on:
      - api
volumes:
  db_data:

In this example, I’ve defined database, api, and frontend services. Each service has a basic image or build command, a restart policy, necessary environment variables, and ports. Specifying inter-service dependencies with depends_on ensures that services start in the correct order. The volumes definition ensures the persistence of database data. This simple approach allows me to quickly test the core functionalities of the application and avoid getting bogged down with unnecessary details in the initial iterations.

This minimal structure is a method I frequently use when testing a new module for a production ERP or developing the backend for my own Android application. Especially when I need to quickly validate a concept, avoiding the overhead of complex YAML files helps me maintain project momentum. Instead of trying to perfect everything at first, starting with a working skeleton allows me to see the next steps more clearly.

2. Smartly Managing Environment Variables: .env and Compose Variables

When self-hosting my applications, I never want sensitive information (API keys, database passwords) to remain explicitly written within the docker-compose.yaml file. This poses both a security risk and makes managing configuration across different environments (development, test, production) difficult. This is where .env files and Docker Compose’s variable management capabilities come into play. For me, this is one of the cornerstones of a flexible and secure deployment.

An .env file is a simple text file located in the same directory as your docker-compose.yaml file, containing key-value pairs:

# .env file
POSTGRES_PASSWORD=mysupersecretpassword
API_KEY=anothersecretkey
FRONTEND_PORT=3000

In the docker-compose.yaml file, I can reference these variables using $VARIABLE_NAME or ${VARIABLE_NAME}:

version: '3.8'
services:
  database:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: myapp_db
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # Pulled from .env file
    volumes:
      - db_data:/var/lib/postgresql/data
  api:
    build: ./api
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://myuser:${POSTGRES_PASSWORD}@database:5432/myapp_db
      API_SECRET: ${API_KEY}
    depends_on:
      - database
  frontend:
    build: ./frontend
    ports:
      - "${FRONTEND_PORT}:${FRONTEND_PORT}" # Pulled from .env file
    depends_on:
      - api
volumes:
  db_data:

This way, I can separate sensitive information from the YAML file and exclude the .env file from version control systems like git (I usually add it to .gitignore). Furthermore, I can reuse the same docker-compose.yaml file across different environments by using different .env files. For example, I can create dev.env for the development environment and prod.env for production, and then spin up specific environments with commands like docker compose --env-file prod.env up -d.

When working on a production ERP, managing database connection details or external service keys between test and production environments was always a challenge. .env files made these transitions seamless, allowing us to safely use the same Compose file for different environments. This way, we not only limited the concept of “working code” to application code but also included deployment configuration within this scope.

3. Correctly Using Volumes for Persistent Data

Containers are inherently ephemeral; that is, all data within them is lost when they are stopped or deleted. When I need persistent data in my self-hosted applications, such as databases, user uploads, or configuration files, using Docker Volumes is essential. Incorrect volume configuration can lead to serious problems, such as losing all data when a server restarts. Therefore, correctly configuring volumes is critical for application continuity and data security.

I use two main types of volumes in Docker Compose:

  1. Named Volumes: These are volumes managed by Docker, usually visible with the docker volume ls command. They are the best way to store data independently of the container and are often preferred for critical data like databases.

    version: '3.8'
    services:
      database:
        image: postgres:15-alpine
        volumes:
          - db_data:/var/lib/postgresql/data # Named volume
    volumes:
      db_data: # Volume definition
  2. Bind Mounts: These directly mount a specific directory from the host machine into the container. This is useful for instantly reflecting code changes during development or providing configuration files from the host to the container. However, for databases in production, Named Volumes are a safer and more manageable option.

    version: '3.8'
    services:
      api:
        build: ./api
        volumes:
          - ./api:/app # Binds host directory to container

When I use named volumes like db_data, my database data remains safe even if I stop the services with docker compose down. This provides great peace of mind, especially when I need to restart a service due to an update or an error.

I remember accidentally binding database data to a temporary directory on the host using a bind mount in a client project. All data was lost when the server restarted. This incident painfully taught me the difference between Named Volumes and Bind Mounts and when each should be used. Since then, I always make sure to use Named Volumes for critical data and even design my backup strategy with these volumes in mind.

4. Optimizing Network Configuration: Custom Networks and Port Mapping

When running multiple services with Docker Compose, inter-service communication needs to be both secure and efficient. By default, Docker Compose creates a single default network for your application and connects all services to it. However, when more isolation, security, or a complex network topology is required, defining custom networks and optimizing port mapping strategies becomes very useful.

For example, a frontend service should only be accessible by an Nginx reverse proxy, while a backend service should only be accessible from the frontend or another internal service. In this scenario, I can restrict inter-service access by defining different networks:

version: '3.8'
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    networks:
      - external_network
      - internal_network
  frontend:
    build: ./frontend
    networks:
      - internal_network
  api:
    build: ./api
    networks:
      - internal_network
  database:
    image: postgres:15-alpine
    networks:
      - internal_network
    environment:
      POSTGRES_DB: myapp_db
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
    volumes:
      - db_data:/var/lib/postgresql/data

networks:
  external_network:
    driver: bridge
  internal_network:
    driver: bridge
volumes:
  db_data:

In this configuration, the nginx service is on both the external_network (internet-facing) and the internal_network (inter-application service communication). The frontend, api, and database services, however, only run on the internal_network. This prevents direct external access to the database service. Additionally, services can communicate with each other by their names (e.g., the api service can access the database service via database:5432), which simplifies configuration.

When developing an internal platform for a bank, different services needed to operate in different security zones. By using Custom networks, we achieved strict isolation between these zones. By allowing only specific services to communicate with each other, we limited the spread of a potential security breach. This is a reflection of network segmentation and Zero-Trust architecture principles, not just in Docker Compose environments, but in general. Nginx being only externally exposed, while other services reside securely within their internal networks, is a fundamental step in such architectures.

5. Increasing Reliability with Health Checks and Restart Policies

A self-hosted application doesn’t just need to be “running”; it also needs to be “healthy” and capable of automatically recovering from unexpected situations. Docker Compose allows us to increase this reliability with its healthcheck mechanism and restart policies. Understanding whether a service is truly operational and ensuring it automatically restarts when it crashes significantly reduces my operational burden.

A healthcheck definition allows Docker to periodically check the status of the application inside a container. For example, for a web service, we can check if it responds by making a request to a specific HTTP endpoint:

version: '3.8'
services:
  api:
    build: ./api
    ports:
      - "8000:8000"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s
    restart: unless-stopped
    depends_on:
      - database

In this example, I’ve defined a healthcheck for the api service. Docker will make a curl request to http://localhost:8000/health every 30 seconds. If it doesn’t receive a response within 10 seconds or if an HTTP error code is returned (with curl’s -f flag), it will be counted as a failure. After three failed attempts, the container will be marked as unhealthy. start_period allows sufficient time for the application to start; even if the health check fails during this period, a restart won’t be triggered.

restart policies determine when a container will automatically restart:

  • no: No automatic restart.
  • on-failure: Restarts if the container exits with an error code (non-zero).
  • always: The container is always restarted (until manually stopped).
  • unless-stopped: The container is always restarted unless manually stopped (my most frequently used).

I was using a Redis service in the backend of my own side product, and sometimes the Redis container would crash due to out-of-memory (OOM) issues. Thanks to the restart: unless-stopped policy, Redis would automatically restart, ensuring my application ran without interruption. However, adding a healthcheck to verify that Redis was truly operational ensured that the application could actually serve requests, beyond just the container being alive. This dual approach is an indispensable combination for increasing the resilience of my self-hosted systems.

graph TD;
  A["User Access"] --> B["Nginx Reverse Proxy"];
  B --> C{"External_Network"};
  C --> N["Nginx"];
  N --> D{"Internal_Network"};
  D --> E["Frontend Service"];
  E --> F["API Service"];
  F --> G["Database Service (PostgreSQL)"];
  style A fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;
  style B fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;
  style C fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;
  style N fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;
  style D fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;
  style E fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;
  style F fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;
  style G fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;

Conclusion

Docker Compose is an invaluable tool for someone with field experience like me, for quickly and reliably spinning up self-hosted applications. Starting quickly with minimalist docker-compose.yaml files, smartly managing environment variables with .env, using Named Volumes for critical data, optimizing network configuration with Custom Networks, and finally increasing application resilience with Health Checks and Restart Policies are my frequently used core strategies.

These approaches not only run an application but also make it more manageable, secure, and flexible. Each step has been distilled from real-world problems and solutions I’ve encountered. I hope this guide helps you get the most out of Docker Compose in your own self-hosted projects and saves you from operational headaches. In my next post, perhaps I’ll share my experiences on CI/CD integration with Docker Compose.

Paylaş:

Bu yazı faydalı oldu mu?

Yükleniyor...

How was this post?

Frequently Asked Questions

Common questions readers have about this article.

What tools and methods should I use to quickly run self-hosted applications with Docker Compose?
I generally use the tools and methods necessary to reduce the initial setup cost with minimal Compose files. Docker Compose has always been a practical solution for me, especially for single-host environments, for quickly deploying and managing applications. It allows you to configure all your application's services, networks, and storage volumes from a single place using a YAML file.
What advantages do I gain when running self-hosted applications with Docker Compose?
Running self-hosted applications with Docker Compose provides great convenience for rapid prototyping, development environments, and small-scale production deployments. Additionally, you can spin up, stop, and manage applications with complex dependencies with a single command. In my experience, this approach is ideal for running applications quickly and efficiently.
What best practices should I follow when creating Docker Compose files?
I usually start with the most basic requirements and iteratively develop the file as the application runs and needs become clearer. Trying to create a massive `docker-compose.yaml` file by thinking of every detail at first is both a waste of time and unnecessary fatigue. Therefore, I believe starting with minimum requirements and developing the file as needed is the best approach.
How do I resolve errors I might encounter when running self-hosted applications with Docker Compose?
In my experience, to resolve errors you might encounter when running self-hosted applications with Docker Compose, you should first carefully examine the error messages and check the status of the relevant services. Additionally, Docker Compose's health checks and logging features can also facilitate the debugging process. I generally use these tools to quickly identify and resolve errors.
ME

Mustafa Erbay

Sistem Mimarisi · Network Uzmanı · Altyapı, Güvenlik ve Yazılım

2006'dan bu yana sistem mimarisi, network, sunucu altyapıları, büyük yapıların kurulumu, yazılım ve sistem güvenliği ekseninde çalışıyorum. Bu blogda sahada karşılığı olan teknik deneyimlerimi paylaşıyorum.

Kişisel Notlar

Bu notlar sadece sizde saklanır. Tarayıcınızda yerel olarak tutulur.

Hazır 0 karakter

Comments

Server-side AI Moderation

Comments are AI-moderated server-side and stored permanently.

?
0/2000

Server-side AI moderation

✉️ Free · No spam · Unsubscribe anytime

Get notified about new posts

New content and technical notes — straight to your inbox.

  • 📌
    Best of the week Single most-worth-reading post
  • 🔧
    Toolbox notes Real tools I used this week
  • 🧠
    Behind-the-scenes Notes that don't make it to blog

We don't spam. Unsubscribe anytime. · Tracked only by Umami (self-hosted, no Google).

Your Reading Stats

0

Posts Read

0m

Reading Time

0

Day Streak

-

Favorite Category

Related Posts