Skip to main content
Complete guide to Dockerize apps and services

Complete guide to Dockerize apps and services

I have a server where I’ve deployed many services and applications that I use daily: web servers like Tutaim, Telegram bots, and game servers, among other things.

But what happens if one day the datacenter where my VPS is located burns down, everything gets wiped, or anything terrible happens that makes it unusable? I would lose absolutely all my services and would have to configure everything from scratch on a new server, which could easily take hours of work.

That’s why I thought about dockerizing all the services and applications I can. This way, if a catastrophe occurs, I can deploy them in minutes on any other server starting from scratch. To do this, in this guide I’ll explain the entire process with a real example: dockerizing a Telegram bot, pushing the image to GitHub, and deploying it.

1. Install Docker: Engine or Desktop?
#

There are several ways to install Docker. We are interested in Docker Engine, the native engine we’ll use to create and run containers. However, there’s also Docker Desktop, which is a packaged GUI application that includes the engine, but packs a ton of extra stuff (and virtual machines) to make it “easy to use”.

In the following table, you can quickly see the key differences between both options:

FeatureDocker EngineDocker Desktop
ArchitectureBackground process (dockerd) + CLI.GUI App + Managed VM + Embedded Engine.
PerformanceNative (bare-metal). Super light and fast.Heavy. Drags along a Virtual Machine and a graphical interface.
SystemsNative Linux (Requires manual VM on Windows/Mac).Windows, Mac, and Linux (runs everything via VM).
InterfacePure terminal (CLI).User-friendly graphical interface.
Cost100% Open Source and free.Free for personal use. Paid for large companies.
Ideal useThe absolute standard for production servers.Only recommended for comfortable local development.

Keeping these features in mind, choose one of the two. If you want maximum performance and you’re on Linux, stick with Docker Engine. If you want visual comfort on Windows or Mac, use Docker Desktop. Do not install both options at the same time on Linux, or you will face severe conflicts when running commands.

I’m going to use Docker Engine on Linux, which is installed very easily by following these steps:

  1. Download and install the official script:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
  1. Fix permissions (to be able to use Docker without typing sudo all the time):
sudo usermod -aG docker $USER
Important

After running this command, log out or restart your PC for the permissions to apply.

2. Prepare the application
#

The application I’m going to use to create the image is a simple Telegram bot that tells the time. I’ll create a folder with the 5 files I need:

1. requirements.txt The file containing the required Python libraries.

python-telegram-bot==20.8
python-dotenv==1.0.1
pytz==2024.1

2. .env The environment variables file. This file is a secret and must always remain local.

TELEGRAM_TOKEN=123456:ABC-YourFakeTokenHere
TIMEZONE=Europe/Madrid

3. .dockerignore Here we add all temporary cache files, IDE garbage, and most importantly, the .env file. Everything listed here will NOT be added to the public image.

__pycache__/
*.pyc
.env
.git/
venv/

4. main.py The code for my bot.

import os
import logging
from datetime import datetime
import pytz
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import ApplicationBuilder, ContextTypes, CommandHandler

# 1. Load environment variables (Hides your keys)
load_dotenv()

# 2. Configure Logging (To see errors in the console)
logging.basicConfig(
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    level=logging.INFO
)

# 3. Bot Logic
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text("Hello! I'm TimeBot. Use /time to know what time it is.")

async def get_time(update: Update, context: ContextTypes.DEFAULT_TYPE):
    # We grab the timezone from the .env or use Madrid by default
    tz_name = os.getenv("TIMEZONE", "Europe/Madrid")
    try:
        zone = pytz.timezone(tz_name)
        current_time = datetime.now(zone).strftime("%H:%M:%S")
        await update.message.reply_text(f"In {tz_name} the time is: {current_time}")
    except Exception as e:
        logging.error(f"Timezone error: {e}")
        await update.message.reply_text("I had an issue calculating the time.")

# 4. Main Execution
if __name__ == '__main__':
    token = os.getenv("TELEGRAM_TOKEN")
    if not token:
        raise ValueError("NO TOKEN PROVIDED! Check your .env file")

    application = ApplicationBuilder().token(token).build()

    application.add_handler(CommandHandler('start', start))
    application.add_handler(CommandHandler('time', get_time))

    application.run_polling()

5. Dockerfile

The Dockerfile is the instruction file to create the image. If you’ve never written a Dockerfile, it might feel like you’re writing spells in an ancient language, but in reality, it’s the dumbest instruction manual in the world. It’s like handing an intern a sheet of paper telling them exactly how to set up a computer from scratch.

FROM python:3.12-slim

# Argument to detect architecture (amd64/arm64)
ARG TARGETARCH

WORKDIR /app

# Install system dependencies if needed (e.g., ffmpeg, curl...)
# RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .

# Install Python libraries
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Startup command
CMD ["python", "main.py"]

What exactly does this Dockerfile do?
#

Docker reads this file from top to bottom, executing each instruction and saving the result as a “layer” of the image. Let’s break down the most important instructions we’ve used:

  • FROM python:3.12-slim (The base) This is always the first instruction. It tells Docker: “Download a lightweight Linux that already comes with Python 3.12 pre-installed.” This saves us from having to install the language manually.

  • ARG TARGETARCH (The architecture) This instruction is preparatory. It tells Docker to be ready to receive a magic external variable while it is building the image. It doesn’t make your image multi-architecture on its own (the buildx command we’ll see later handles that), but it is vital to include it if your application grows. Why? Because if tomorrow you need to install a Linux program that is named program-x64 on normal processors (Intel/AMD) and program-arm on a Raspberry Pi (ARM), you can use this variable inside the Dockerfile to say: “If TARGETARCH is amd64 download the first one, if it’s arm64 download the second one.” We aren’t actively using it in the RUN commands right now, but it’s a good practice to leave the Dockerfile prepared.

  • WORKDIR /app (The working directory) It tells the container: “From now on, run all commands and copy all files inside the /app folder.” Obviously, you can store your files wherever you want inside your Docker image.

  • RUN apt-get update... (Execute Linux commands) Using RUN followed by something is the equivalent of opening that virtual Linux terminal and typing that exact thing. Here you can install OS-level programs that your application might need under the hood (like ffmpeg if you’re processing audio/video, or curl).

Note

If you’re going to execute multiple system commands, always chain them with && in a single RUN. If you put 10 separate RUN commands, Docker will create 10 useless “layers” and your image will be much heavier. For a better optional understanding of Docker layers, you can review its official explanation.

  • COPY requirements.txt . (Throw your files in the pot) Copies files from your computer into the image. Notice that we first copy only the requirements file and then run pip install in the next RUN. We do this to take advantage of Docker’s caching system: if you modify your code in main.py but don’t touch the libraries, Docker will skip the pip installation and build the image much faster.
  • COPY . . (The final dump) Copies EVERYTHING inside your project folder (your source code, folders, etc.) into the image. Anything you don’t want to include (like the .env with your real keys or the temporary __pycache__ files), must be listed in the .dockerignore file.
  • CMD ["python", "main.py"] (The power button) This is the final order. It tells the container what it has to do when someone turns it on on the server. Be careful with this: if the main.py script crashes or finishes executing, the entire container will shut down instantly. Docker only keeps the container alive as long as the CMD process keeps running in the foreground.

Additionally, this file is prepared with the TARGETARCH variable in case we want to install different dependencies in the future depending on whether the server uses Intel/AMD processors (amd64) or ARM processors like a Raspberry Pi (arm64).

3. Upload the image to GitHub (GHCR)
#

To be able to download our application from any server in the world, we are going to host the Docker image in the GitHub Container Registry.

To do this, you need a Token. Generate a new one and make sure to check the write:packages box. Copy that token.

Screenshot of the GitHub token scope

Now, open your terminal and log in to Github:

docker login ghcr.io -u YourGitHubUsername

Paste the generated token when prompted.

4. Native and multi-architecture build
#

Multi-architecture
#

Normally, if you build the image on your PC (amd64), it won’t work on ARM machines (arm64) like a Raspberry Pi. To solve this and build both versions at once, we’ll use Docker Buildx.

Run these three commands in your local terminal, inside the project folder:

# 1. Install emulators so your PC can compile ARM (only the first time)
docker run --privileged --rm tonistiigi/binfmt --install all

# 2. Create an advanced builder (only the first time)
docker buildx create --use

# 3. Compile both versions at once and automatically push them to GitHub
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/YourGitHubUsername/timebot:latest --push .

Once it finishes, the image will be available in the “Packages” tab of your GitHub profile:

Timebot package on GitHub

Native
#

If you’re only interested in building the image for your current system’s architecture, simply run these commands and you’re done:

# 1. Build the image
docker build -t ghcr.io/YourGitHubUsername/timebot:latest .

# 2. Push it to the GitHub registry
docker push ghcr.io/YourGitHubUsername/timebot:latest
Important

ghcr.io/YourGitHubUsername/timebot:latest is not just the image name, it’s the entire shipping address, as if it were a postal package label. We’re telling it to push the image to the GitHub Container Registry under the /YourGitHubUsername/timebot path.

:latest is the specific version of the image. In a professional environment, a numerical tag is usually used, but this works fine for us.

5. Deployment on the server (the orchestrator)
#

We finally arrive at the production server. Access a terminal on your server or VPS.

At this point, you could turn on your container using a ridiculously long docker run command in the terminal, but that’s a bad practice. If the server restarts, the container won’t start by itself and you’ll lose the configuration.

To fix this we use Docker Compose, which is a “local orchestrator”. It allows us to write a configuration file (docker-compose.yml) where we define how we want our container to behave, if it needs to restart automatically, what ports it uses, and where it reads passwords from.

Since our bot’s code is already inside the image hosted on GitHub, you don’t need to upload or copy your Python files to the VPS. You only need to tell Docker Compose to download the image and turn it on.

Create a new folder on your server, enter it, and create your credentials file. In our case, it does need one, since it’s a file that doesn’t come with the image; it must be created locally for security.

mkdir timebot && cd timebot
nano .env

Next, create the orchestrator. The name must be docker-compose.yml or compose.yml. In recent versions, Docker’s documentation recommends using compose.yml, although docker-compose.yml is still completely valid.

nano docker-compose.yml

And paste the following configuration:

services:
  timebot:
    image: ghcr.io/YourGitHubUsername/timebot:latest
    container_name: timebot_prod
    restart: always
    env_file:
      - .env

What does each line of the docker-compose.yml mean?
#

  • services:: This is where the list of applications we are going to boot up begins. In our case, just one (our bot).
  • timebot:: This is the internal name we give to the service. If we were to add a PostgreSQL database in the future, we would put it underneath with the name db:.
  • image:: Tells Docker exactly where it has to download the package from. By appending :latest, we ensure it always points to the latest version we’ve uploaded to GitHub.
  • container_name:: This is the “pretty” name you’ll see when you list your server’s processes with docker ps.
  • restart: always: The lifeline. It tells Docker that if the Python script crashes due to an error, or if the entire VPS server restarts due to a power outage, Docker must turn the bot back on automatically.
  • env_file:: Securely injects the passwords and configurations we saved in our server’s .env file, so the Python code can read the TELEGRAM_TOKEN without it being public on GitHub.

To see all the options besides the ones explained here that the Docker Compose file offers, I recommend checking the official Docker documentation.

Once the file is saved, start the bot in the background by running:

docker compose up -d

(The -d or “detached” parameter is used so the bot stays running in the background and gives you back control of the terminal).

We execute it and wait for it to download the image and boot it up:

ubuntu@myvps:~/dockers/timebot$ docker compose up -d
[+] up 11/11
 ✔ Image ghcr.io/mr-umar/timebot:latest Pulled                                                                                                                                                                  2.4s
 ✔ Network timebot_default              Created                                                                                                                                                                 0.5s
 ✔ Container timebot_prod               Started                                                                                                                                                                 0.3s
ubuntu@myvps:~/dockers/timebot$ 

To verify that our container is working, we can check it with docker ps:

ubuntu@myvps:~/dockers/timebot$ docker ps
CONTAINER ID   IMAGE                                     COMMAND                  CREATED              STATUS                 PORTS                                     NAMES
4ba9a9dd53df   ghcr.io/mr-umar/timebot:latest            "python main.py"         About a minute ago   Up About a minute                                                timebot_prod

As we can see, the bot is now running in the cloud securely, isolated, and ready to survive reboots.

Screenshot of the Telegram bot

6. Updating in the future (The DevOps cycle)
#

Have you modified the code on your computer and want to update the application on the VPS? This is the process you should follow:

  1. On your PC: Run the push command again.
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/YourGitHubUsername/timebot:latest --push .

or

# 1. Build the image
docker build -t ghcr.io/YourGitHubUsername/timebot:latest .

# 2. Push it to the GitHub registry
docker push ghcr.io/YourGitHubUsername/timebot:latest
  1. On your VPS: Download the update and restart the container.
docker compose pull
docker compose up -d

With these two simple steps, Docker detects the new version, shuts down the old container, and boots up the new one in a matter of seconds. You now have a complete DevOps system that will save your life the next time your server decides to catch fire.

Conclusion
#

This Telegram bot is just the excuse: the workflow is the same for almost any app or service you want to make “fireproof” (Dockerfile → build → push to a registry → deploy with Compose on the server → update with pull + up). The magic isn’t the example; it’s that you separate code (inside the image) from config/secrets (outside, in .env/variables), and this way you can recreate everything in minutes on any clean machine without copying projects by hand.

From here on out, your job is to repeat the pattern: if the app uses ports, declare them in Compose; if it needs persistence, add volumes; if it depends on other services (DB, Redis), add them as more services and you’re set. To avoid messing up, when you want to do something “weird” (healthchecks, networks, volumes, resource limits, depends_on, etc.), rely first on the official Docker Compose documentation, because that’s where all the options are with their real and updated behavior. And yes: you can use an LLM to generate the skeleton of the Dockerfile/compose.yml or to adapt the deployment to your case, but then don’t be an NPC: test it locally, and version/tag your images if you don’t want surprises.

Umar Mohammad
Author
Umar Mohammad
Cybersecurity analyst and Telecommunications engeneering student