From Zero to Compose Hero
Right. Let’s start at the very beginning.
Docker is a way to package an app and everything it needs into something called a container. Think of a container like a shipping container in a port. Same size every time, easy to stack, fits anywhere, and you do not have to care what is inside as long as it works.
Why is that good?
Because without containers, installing software can feel like assembling IKEA furniture with the wrong screws and instructions in ancient Sumerian.
With containers, you just tell Docker what you want and it delivers a ready-to-run app without sending you on a dependency treasure hunt.
Running one container is simple. Running several together, like a website, a database and maybe a cache, is where chaos begins. Suddenly you are juggling terminal commands, trying to remember which network connects to what, and wondering why your database is ghosting your web server.
That is when Docker Compose steps in. Calm, organised and ready to make you look like you know what you are doing.
Here, we run it on Ubuntu Server because we like knowing exactly what is under the hood. But Docker Compose will run on almost anything that can handle Docker. Linux, macOS, Windows, Raspberry Pi, even that old ThinkPad your cousin left behind.
Protip: See my other post below about server choice.

What is Docker Compose really?
It is a way to write down, in one YAML file, all the services you want to run, what images they use, which ports they expose, what volumes they store data in, and which networks connect them. Then you bring them to life with one simple command:
docker compose up -dThe -d means “detached mode”, which is tech-speak for “run in the background so you can get on with your day”.
A quick example
version: "3.9"
services:
web:
image: nginx:latest
ports:
- "8080:80"
db:
image: postgres:16
environment:
POSTGRES_USER: example
POSTGRES_PASSWORD: example
POSTGRES_DB: exampledb
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
Yes, this is a terrible Docker Compose file. Please don’t actually run it… unless you genuinely just want an Nginx web server and a PostgreSQL database sitting around doing nothing in particular.
Explanation:
- version: "3.9" Tells Docker Compose which syntax version this file is using.
- services: Here we list every container we want to run.
- web: The name of our first container.
- image: nginx:latest Pulls and runs the latest version of the Nginx web server.
- ports: Maps ports between the host and the container.
8080:80means port 8080 on the server connects to port 80 inside the container. - db: The name of our database container.
- image: postgres:16: Pulls and runs PostgreSQL version 16.
- environment: Sends settings into the container as environment variables.
- POSTGRES_USER The username for the database.
- POSTGRES_PASSWORD The password for the database.
- POSTGRES_DB The name of the default database.
- volumes: Links a named volume (
db_data) to the database storage folder so data is kept even if the container restarts. - volumes: db_data: Creates the named volume used for persistent storage.
Save that as docker-compose.yml, run docker compose up -d, and suddenly you have an Nginx web server and a PostgreSQL database having a private chat inside your server.
Taking it further
Now that we’ve looked at the simplest possible Compose file, let’s take one small step toward something you could actually keep running without feeling embarrassed. It’s still small, but we’ll add a few things that make life much easier when you start running real services.
We will use a .env file to store configuration values. This keeps sensitive data out of the main Compose file and makes it easier to reuse settings across multiple projects.
Example .env file:
TZ=Europe/Stockholm
PUID=1000
PGID=1000
APPDATA=/dockers/data/postgres
APPCONFIG=/dockers/configs/nginx
POSTGRES_USER=example
POSTGRES_PASSWORD=example
POSTGRES_DB=exampledb
And the updated docker-compose.yml:
version: "3.9"
services:
web:
image: nginx:latest
ports:
- "8080:80"
volumes:
- ${APPCONFIG}:/etc/nginx
environment:
- TZ=${TZ}
restart: unless-stopped
db:
image: postgres:16
environment:
- TZ=${TZ}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- PUID=${PUID}
- PGID=${PGID}
volumes:
- ${APPDATA}:/var/lib/postgresql/data
restart: unless-stopped
volumes:
db_data:What’s new compared to the first Compose file:
- Environment variables from
.envWe no longer hardcode usernames, passwords, timezones or file paths. They now live in a separate.envfile so you can change them without touching the Compose file. - TZ (Timezone) Sets the timezone inside the container. Makes sure logs and scheduled tasks match your local time.
- PUID and PGID Define which user and group IDs the container runs as. Prevents permission problems when it creates files on your host. UID 1000 is usually the main user on Ubuntu.
- APPDATA and APPCONFIG Variables pointing to dedicated folders on the host for data and config files.
/dockers/data/postgresfor PostgreSQL data/dockers/configs/nginxfor Nginx configs - restart: unless-stopped – Makes sure containers restart automatically after a crash or reboot, unless you tell them not to.
- Volume mounts for config – Nginx now mounts its config from
${APPCONFIG}so you can edit it on the host. PostgreSQL mounts its data from${APPDATA}so the database survives restarts.
A .env file is just plain text with key-value pairs. Most images you run in Docker will have certain environment variables they expect you to set, for example TZ for timezone, PUID and PGID for file permissions, or database credentials. These are important because the application inside the container actually uses them. You will have to look them up before you add them to your compose file.
But you are not limited to just the variables the app needs. You can add anything you want, and then use those values anywhere in your Compose file.
For example:
TZ=Europe/Stockholm
APPDATA=/dockers/data/postgres
APPCONFIG=/dockers/configs/nginx
FAVORITE_PIZZA=Hawaii
Then in your Compose file:
environment:
- TZ=${TZ}
- FAVORITE_PIZZA=${FAVORITE_PIZZA}When the container starts, all of these values become real environment variables inside it. That means you could open a shell into the container and run:
echo $FAVORITE_PIZZA
…and it would happily print Hawaii. That variable does nothing for the container itself, but it’s available for you, your scripts, or anything else running inside.
So .env is both a way to pass the right settings to an app, and a flexible place to store extra variables for your own use.
10 extra tips for logging, security and general Docker sanity
- Keep logs outside the container Mount a folder on the host for logs so you can rotate and back them up without touching the container.
- Set the correct timezone Use
TZin.envso your logs match local time. Makes troubleshooting a lot easier. - Use
restart: unless-stoppedSaves you from having to restart containers manually after reboots or crashes. - Run as a non-root user Use
PUIDandPGIDto avoid containers creating files as root on your host. - Limit open ports Only expose what’s necessary. Keep admin interfaces on internal networks.
- Put internet-facing services behind a reverse proxy Add HTTPS and basic auth where you can.
- Do not commit
.envfiles to Git They often contain passwords and API keys. - Name your volumes and networks clearly Helps avoid the “what is this volume from 2022” mystery.
- Monitor resource usage Keep an eye on CPU, RAM and disk usage with
docker statsor other tools. - Document your setup A simple README in your config folder can save you (or future you) hours of head scratching.
This setup is still small, but it’s a step toward how you’ll want to run real services in production. You have cleaner configs, persistent storage in known locations, and fewer surprises when you move or restore a container.
Anouk’s opinion? She does not care as long as the server stays online during nap time.

Member discussion