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:
| Feature | Docker Engine | Docker Desktop |
|---|---|---|
| Architecture | Background process (dockerd) + CLI. | GUI App + Managed VM + Embedded Engine. |
| Performance | Native (bare-metal). Super light and fast. | Heavy. Drags along a Virtual Machine and a graphical interface. |
| Systems | Native Linux (Requires manual VM on Windows/Mac). | Windows, Mac, and Linux (runs everything via VM). |
| Interface | Pure terminal (CLI). | User-friendly graphical interface. |
| Cost | 100% Open Source and free. | Free for personal use. Paid for large companies. |
| Ideal use | The 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:
- Download and install the official script:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh- Fix permissions (to be able to use Docker without typing
sudoall the time):
sudo usermod -aG docker $USERAfter 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.12. .env
The environment variables file. This file is a secret and must always remain local.
TELEGRAM_TOKEN=123456:ABC-YourFakeTokenHere
TIMEZONE=Europe/Madrid3. .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 (thebuildxcommand 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 namedprogram-x64on normal processors (Intel/AMD) andprogram-armon a Raspberry Pi (ARM), you can use this variable inside the Dockerfile to say: “IfTARGETARCHis amd64 download the first one, if it’s arm64 download the second one.” We aren’t actively using it in theRUNcommands 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/appfolder.” 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 (likeffmpegif you’re processing audio/video, orcurl).
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 runpip installin the nextRUN. We do this to take advantage of Docker’s caching system: if you modify your code inmain.pybut don’t touch the libraries, Docker will skip thepipinstallation 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.envwith your real keys or the temporary__pycache__files), must be listed in the.dockerignorefile.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 themain.pyscript crashes or finishes executing, the entire container will shut down instantly. Docker only keeps the container alive as long as theCMDprocess 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.

Now, open your terminal and log in to Github:
docker login ghcr.io -u YourGitHubUsernamePaste 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:

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:latestghcr.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 .envNext, 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.ymlAnd paste the following configuration:
services:
timebot:
image: ghcr.io/YourGitHubUsername/timebot:latest
container_name: timebot_prod
restart: always
env_file:
- .envWhat 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 namedb:.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 withdocker 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.envfile, so the Python code can read theTELEGRAM_TOKENwithout 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
-dor “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_prodAs we can see, the bot is now running in the cloud securely, isolated, and ready to survive reboots.

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:
- 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- On your VPS: Download the update and restart the container.
docker compose pull
docker compose up -dWith 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.

