👍 What we like
- ✓A single compose.yaml file describes and redeploys the whole stack.
- ✓Validate the file before starting with docker compose config.
- ✓Healthcheck: phpMyAdmin only starts once the database is ready.
- ✓Simple updates with docker compose pull then up -d.
👎 What to watch
- ✕Requires understanding YAML, Docker volumes and networks.
- ✕Published ports bypass ufw/firewalld, so publish them carefully.
- ✕Publishing over HTTPS on the Internet requires an extra reverse proxy.
- ✕You still have to organize database backups yourself.
🏆 Our picks
Affiliate links · same price for youSynology DS2419+ NAS
Amazon · price and availability on the listing
Crucial P3 Plus SSD 500GB
Amazon · price and availability on the listing
See all our recommended gear in the shop.
📑 Contents ▾
- 01 Prerequisites
- · Which hardware?
- 03 Step-by-step installation
- · Step 1: Remove unofficial packages and add the Docker repository
- · Step 2: Install Docker Engine and the Compose plugin
- · Step 3 (optional): Use Docker without sudo
- · Step 4: Create the project and the .env file
- · Step 5: Write the compose.yaml file
- · Step 6: Configure Nginx and the home page
- · Step 7: Start the stack
- · Step 8: Reach phpMyAdmin through an SSH tunnel
- 12 Verification checks
- 13 Security and best practices
- 14 Troubleshooting common errors
- · docker-compose: command not found
- · required variable MARIADB_ROOT_PASSWORD is missing a value
- · Bind for 0.0.0.0:8080 failed: port is already allocated
- · dependency failed to start: container ... is unhealthy
- · The new password in .env is not applied
- · no matching manifest for linux/arm/v7
- 21 FAQ
- · Can I publish this site over HTTPS on the Internet?
- · docker-compose.yml or compose.yaml?
- · How do I add a service?
- · How do I limit a container’s memory?
- 26 Read also
- 27 Host this yourself
This tutorial walks you through building your first Docker Compose stack on a homelab server: an Nginx web server, a MariaDB database and the phpMyAdmin interface to manage it. Every file provided is valid as is. Each step ends with a check that actually tests something. The goal is to learn good habits: the official Docker repository, the docker compose plugin (Compose v2), passwords kept out of the YAML file, separate networks, a healthcheck, and an admin interface that is never exposed to the Internet.
The stack is meant for your local network. Publishing over HTTPS on the Internet is covered separately in the FAQ, because it requires a reverse proxy and some security planning.
Prerequisites
- A server running Debian 12 or 13, or Ubuntu 22.04, 24.04 or 26.04 LTS, with a
sudoaccount and SSH access from the local network only. These are the versions listed in the Docker documentation at the time of writing: check the list for your version. - An amd64 or arm64 architecture for this particular stack. The official
mariadbimage is only published for amd64, arm64, ppc64le and s390x (there is no 32-bit ARM build). - Ports 8080 and 8081 free on the server.
- No domain name and no port forwarding on your router: nothing will be exposed to the Internet.
Which hardware?
- ASUS Tinker Board S: this board uses a Rockchip RK3288 SoC (ARMv7, 32-bit) with 2 GB of RAM and 16 GB of eMMC. Docker provides armhf packages for Debian, and the
nginxandphpmyadminimages exist for arm32v7. However, there is no 32-bit build of the officialmariadbimage, so this stack will not start as is on this board. It suits lightweight single-container services, not this example. - Synology DS2419+ NAS: this NAS has an Intel Atom C3538 processor (x86-64). On DSM 7.2 or later, the Container Manager package lets you deploy a Compose file from Container Manager > Project > Create. Check in Package Center that Container Manager is available for your model and DSM version, and confirm the exact labels in the built-in help. If you go this route, skip steps 1 to 3 below.
- Crucial P3 Plus SSD 500GB: this is an M.2 NVMe SSD. It works as the system drive of a mini PC or x86 server with an M.2 NVMe slot. The Tinker Board S has no M.2 slot. The DS2419+ has no built-in one: Synology’s M2D18 expansion card is meant for SSD caching, so check Synology’s compatibility list before buying anything.
Step-by-step installation
Step 1: Remove unofficial packages and add the Docker repository
The distribution packages docker.io and docker-compose conflict with the official packages. The Docker documentation says to remove them first. This command does not delete your existing images or volumes in /var/lib/docker.
sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)
sudo apt update
sudo apt install -y ca-certificates curl openssl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL "https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
$ID is set to debian or ubuntu, so the same block works on both distributions.
Check: the repository must offer the docker-ce package.
apt-cache policy docker-ce | head -n 3
The Candidate: line must show a version, not (none).
Step 2: Install Docker Engine and the Compose plugin
Compose v2 is installed as a plugin (docker-compose-plugin) and run as docker compose, with no hyphen. Do not use the old, manually downloaded docker-compose binary.
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Check:
sudo systemctl is-active docker
sudo docker run --rm hello-world
docker compose version
You should see active, the “Hello from Docker!” message, and version v2.x or later.
Step 3 (optional): Use Docker without sudo
sudo usermod -aG docker $USER
newgrp docker
⚠️ According to the Docker documentation, the
dockergroup grants root-level privileges. Only add trusted accounts. If you skip this step, prefix thedockercommands below withsudo.
Check: docker ps must run without “permission denied”.
Step 4: Create the project and the .env file
Passwords go in a .env file, which Compose reads automatically from the project directory, rather than in plain text in the YAML.
mkdir -p ~/homelab/premier-stack/{nginx,site}
cd ~/homelab/premier-stack
cat > .env <<EOF
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 24)
MARIADB_DATABASE=homelab
MARIADB_USER=homelab
MARIADB_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 .env
Check:
ls -l .env && grep -c '=' .env
The permissions must be -rw------- and the count must be 4. Never commit this file to a public Git repository.
Step 5: Write the compose.yaml file
compose.yaml is the recommended default name, though docker-compose.yml still works. The web server is published on port 8080 of the local network. phpMyAdmin only listens on 127.0.0.1, so it can’t be reached from the network, let alone from the Internet. MariaDB publishes no port at all.
name: premier-stack
services:
web:
image: nginx:stable
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- frontend
db:
image: mariadb:lts
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?set it in .env}
MARIADB_DATABASE: ${MARIADB_DATABASE:-homelab}
MARIADB_USER: ${MARIADB_USER:-homelab}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:?set it in .env}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 3
networks:
- backend
phpmyadmin:
image: phpmyadmin:latest
restart: unless-stopped
environment:
PMA_HOST: db
ports:
- "127.0.0.1:8081:80"
depends_on:
db:
condition: service_healthy
networks:
- backend
networks:
frontend:
backend:
volumes:
db_data:
The ${VAR:?message} syntax stops Compose with a clear error if the variable is empty. The healthcheck follows the MariaDB documentation example: phpMyAdmin only starts once the database is ready.
Check: the command must finish without errors (exit code 0) and print only the confirmation message.
docker compose config --quiet && echo "compose.yaml is valid"
Step 6: Configure Nginx and the home page
The official image already ships a complete nginx.conf (with the events and http blocks) that loads /etc/nginx/conf.d/*.conf. You only need to provide a server block:
cat > nginx/default.conf <<'EOF'
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
add_header X-Content-Type-Options "nosniff" always;
location / {
try_files $uri $uri/ =404;
}
}
EOF
cat > site/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Homelab</title></head>
<body><h1>My first Docker Compose stack is running</h1></body>
</html>
EOF
You’ll check the syntax in the next step, once the container is running.
Step 7: Start the stack
docker compose up -d
docker compose ps
Check: all three services must be Up, and db should show (healthy) after a few seconds. Then test Nginx:
docker compose exec web nginx -t
curl -sI http://localhost:8080 | head -n 1
Expected output: syntax is ok then test is successful, and HTTP/1.1 200 OK. From another machine on the local network, open http://SERVER-IP:8080.
Also check that the application user can reach its database:
docker compose exec db sh -c 'mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "SHOW DATABASES;"'
The homelab database must appear in the list.
Step 8: Reach phpMyAdmin through an SSH tunnel
Because phpMyAdmin only listens on the server’s loopback interface, use an SSH tunnel from your workstation:
ssh -L 8081:127.0.0.1:8081 user@SERVER-IP
Keep this session open, then open http://localhost:8081 on your workstation. Log in with homelab and the MARIADB_PASSWORD from the .env file.
Server-side check:
ss -tln | grep 8081
Only 127.0.0.1:8081 should appear, never 0.0.0.0:8081.
Verification checks
docker compose ps
docker compose logs --tail=20 web
docker compose logs --tail=20 db
The official image sends Nginx logs to the container’s standard output. Read them with docker compose logs, not in /var/log/nginx on the host. After a server reboot, docker compose ps should show the containers running again, thanks to restart: unless-stopped.
Security and best practices
- Firewall: the Docker documentation states that ports published by containers bypass ufw and firewalld rules. Only publish what you need, bind admin interfaces to
127.0.0.1, and do not forward ports 8080 or 8081 on your router. - Keep sensitive interfaces private: phpMyAdmin, SSH, the Docker socket (
/var/run/docker.sock) and SMB shares must never be reachable from the Internet. For remote access, use a VPN (WireGuard, for example). - Secrets: keep
.envatchmod 600. TheMARIADB_*variables are only read on first start, when the volume is empty. - Database backup:
docker compose exec -T db sh -c 'mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --all-databases' > backup-$(date +%F).sql
head -n 5 backup-$(date +%F).sql
The dump header should be displayed. Then copy the file off the server, for example to a NAS.
- Updates:
docker compose pull && docker compose up -d. Read the MariaDB release notes before a major version upgrade.
Troubleshooting common errors
docker-compose: command not found
Compose v2 runs as docker compose, with no hyphen. Check that the docker-compose-plugin package is installed (step 2).
required variable MARIADB_ROOT_PASSWORD is missing a value
The .env file is missing, empty, or in another directory. Run Compose from ~/homelab/premier-stack, then check with docker compose config --environment.
Bind for 0.0.0.0:8080 failed: port is already allocated
Another service is using the port. Find it with sudo ss -tlnp | grep 8080, or replace "8080:80" with a free port.
dependency failed to start: container ... is unhealthy
MariaDB failed its healthcheck. Check docker compose logs db: a full disk or a volume initialized with other credentials are common causes.
The new password in .env is not applied
Initialization variables are ignored when the volume already contains a database. Change the password in SQL. If you are just starting out and the database holds nothing useful, you can delete the volume instead.
⚠️ Destructive: the following command permanently erases all database data. Check the volume name first with
docker volume ls.
docker compose down -v
no matching manifest for linux/arm/v7
Your machine is 32-bit ARM (as with the ASUS Tinker Board S): the mariadb image does not exist for this architecture. Use an amd64 or arm64 machine.
FAQ
Can I publish this site over HTTPS on the Internet?
Yes, but only the web service, and never phpMyAdmin. Certbot’s --nginx plugin edits an Nginx installed on the host, so it doesn’t fit this setup. You need to add a containerized reverse proxy that handles ACME certificates (Caddy, Traefik…) to the stack, or run Certbot in certonly --webroot mode with a volume shared with Nginx. Follow the official documentation of the tool you choose.
docker-compose.yml or compose.yaml?
Both names work. compose.yaml is the default name recommended by Docker.
How do I add a service?
Add a block under services:, validate it with docker compose config --quiet, then run docker compose up -d. Only the changed services are recreated.
How do I limit a container’s memory?
Use the deploy.resources.limits key (for example memory: 512M) under the relevant service, then check the result with docker stats.
Read also
Host this yourself
No hardware to manage? This kind of service runs on a managed VPS, ready in minutes. See PlanetHoster plans