Ravindra BagaleCourses & study guides मराठी

Chapter 57: Docker Projects Part 1: Websites, Databases, LAMP, LEMP, WordPress, 3-Tier and Every Language with MySQL and MongoDB

57.11 Project 11: Self-hosted team tools: Portainer, Gitea, Nextcloud and Uptime Kuma

Why: the problem

Suppose we run IT for a 40-person startup in Pune that cannot send its code and files to outside services because a bank client forbids it. The team needs four things: a web UI to manage Docker, a private Git server, a file-sharing drive, and a monitor that tells them when a site goes down. Installing these four tools by hand on one Ubuntu server takes days, and each one wants its own PHP, Go, database and web server versions. With Docker each tool is one container image, often with its own database container, and all of them sit behind one nginx reverse proxy. We run them one by one first, understand their volumes and ports, and then combine them in one Compose file with a shared proxy network. This is how many real companies, schools and home labs run their internal tools.

What: every term in plain English

  • Portainer — A web UI for Docker. Shows containers, images, volumes, networks and logs, and can deploy Compose stacks.
  • Gitea — A light, self-hosted Git server with pull requests and issues. Written in Go.
  • Nextcloud — A self-hosted file-sharing and office suite, like a private Google Drive. Written in PHP.
  • Uptime Kuma — A monitoring tool that checks websites and ports and sends alerts.
  • Reverse proxy — One web server (nginx here) that receives all traffic and forwards each host name to the right container.
  • docker.sock — The Docker API socket. Mounting it gives a container full control of Docker, so only trusted tools get it.
  • Server name — nginx picks the backend by the Host header, like git.lab.local or drive.lab.local.
  • /etc/hosts — A local file that maps names to IPs. We use it for lab host names instead of real DNS.
Run Portainer alone. Run Gitea with Postgres. Run Nextcloud with MariaDB and Redis. Run Uptime Kuma. Put all behind one nginx. Back up every volume. Clean up.
Four team tools behind one nginx on a shared network

How: step by step

  1. Run Portainer alone. Manage Docker from the browser.
  2. Run Gitea with Postgres. Push a repo over HTTP.
  3. Run Nextcloud with MariaDB and Redis. Upload a file.
  4. Run Uptime Kuma. Monitor the other three.
  5. Put all behind one nginx. Host names on port 80.
  6. Back up every volume. tar from a throwaway container.
  7. Clean up. down -v.

Architecture

Browser: git / drive / status .lab.local nginx proxy :80 (edge network) Gitea + Postgres Nextcloud + MariaDB + Redis Uptime Kuma monitors all Portainer on 127.0.0.1:9443 (docker.sock) One named volume per tool and database
Self-hosted tools architecture

Folder layout

selfhost/
  compose.yaml        (proxy + kuma)
  proxy.conf
  .env
  gitea/compose.yaml
  nextcloud/compose.yaml

Example 1: Portainer: manage Docker from the browser

Goal. Portainer: manage Docker from the browser.

portainer commands

docker volume create portainer-data
docker run -d --name portainer --restart unless-stopped \
  -p 127.0.0.1:9443:9443 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v portainer-data:/data \
  portainer/portainer-ce:2.21.4
docker logs portainer --tail 3

What each line does

  • portainer-ce — The free Community Edition.
  • 9443 — Portainer serves HTTPS with a self-signed certificate on this port.
  • 127.0.0.1: — Only this machine can open the UI. Use an SSH tunnel from your laptop.
  • /var/run/docker.sock — Gives Portainer control of Docker. Treat Portainer like root access.
  • portainer-data — Stores the admin user and settings.

Green output

$ docker logs portainer --tail 2
level=INFO msg="starting HTTPS server" bind_address=:9443
$ ssh -L 9443:localhost:9443 ubuntu@lab-server     # from the laptop
# Browser: https://localhost:9443 -> create admin user within 5 minutes
# Home > local > Containers shows: portainer   running

Expected result. Portainer shows all containers, logs and volumes in the browser.

Example 2: Gitea with Postgres: a private Git server

Goal. Gitea with Postgres: a private Git server.

gitea/compose.yaml

services:
  gitea:
    image: gitea/gitea:1.22
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: postgres
      GITEA__database__HOST: gitea-db:5432
      GITEA__database__NAME: gitea
      GITEA__database__USER: gitea
      GITEA__database__PASSWD: ${GITEA_DB_PASSWORD}
      GITEA__server__ROOT_URL: http://git.lab.local/
    volumes: [gitea-data:/data]
    ports: ["127.0.0.1:3000:3000", "2222:22"]
    depends_on:
      gitea-db: { condition: service_healthy }
    restart: unless-stopped
  gitea-db:
    image: postgres:16
    environment:
      POSTGRES_USER: gitea
      POSTGRES_PASSWORD: ${GITEA_DB_PASSWORD}
      POSTGRES_DB: gitea
    volumes: [gitea-db:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gitea"]
      interval: 5s
      retries: 20
    restart: unless-stopped
volumes:
  gitea-data:
  gitea-db:

What each line does

  • GITEA__database__HOST — Gitea reads settings from GITEA__section__KEY variables, so no app.ini editing.
  • 2222:22 — Git over SSH on host port 2222, because the server SSH already uses 22.
  • gitea-data — Holds repositories, attachments and app.ini.
  • pg_isready — Postgres health check; Gitea starts only after it passes.

Green output

$ cd gitea && docker compose -p gitea up -d --wait
 Container gitea-gitea-db-1  Healthy
 Container gitea-gitea-1     Started
# Browser: http://localhost:3000 > Install Gitea > create admin user ravi
$ git clone http://localhost:3000/ravi/demo.git && cd demo
$ echo hello > README.md && git add . && git commit -m first && git push
 * [new branch]      main -> main

Expected result. A private Git server with its own Postgres, holding code inside the company.

Example 3: Nextcloud with MariaDB and Redis: a private drive

Goal. Nextcloud with MariaDB and Redis: a private drive.

nextcloud/compose.yaml

services:
  app:
    image: nextcloud:30-apache
    ports: ["127.0.0.1:8080:80"]
    environment:
      MYSQL_HOST: nc-db
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_PASSWORD: ${NC_DB_PASSWORD}
      REDIS_HOST: nc-redis
      NEXTCLOUD_ADMIN_USER: admin
      NEXTCLOUD_ADMIN_PASSWORD: ${NC_ADMIN_PASSWORD}
      NEXTCLOUD_TRUSTED_DOMAINS: drive.lab.local localhost
    volumes: [nc-html:/var/www/html]
    depends_on:
      nc-db: { condition: service_healthy }
      nc-redis: { condition: service_started }
    restart: unless-stopped
  nc-db:
    image: mariadb:11.4
    command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
    environment:
      MARIADB_ROOT_PASSWORD: ${NC_ROOT_PASSWORD}
      MARIADB_DATABASE: nextcloud
      MARIADB_USER: nextcloud
      MARIADB_PASSWORD: ${NC_DB_PASSWORD}
    volumes: [nc-db:/var/lib/mysql]
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      retries: 30
    restart: unless-stopped
  nc-redis:
    image: redis:7-alpine
    restart: unless-stopped
  cron:
    image: nextcloud:30-apache
    entrypoint: /cron.sh
    volumes: [nc-html:/var/www/html]
    depends_on: [app]
    restart: unless-stopped
volumes:
  nc-html:
  nc-db:

What each line does

  • nextcloud:30-apache — Nextcloud with Apache and PHP inside the same image.
  • NEXTCLOUD_ADMIN_USER — Creates the admin user on first start, no setup page.
  • TRUSTED_DOMAINS — Nextcloud refuses host names not on this list.
  • READ-COMMITTED — The MariaDB setting Nextcloud documents as required.
  • cron service — Same image running /cron.sh for background jobs every 5 minutes.
  • healthcheck.sh — A script shipped in the mariadb image to check readiness.

Green output

$ cd nextcloud && docker compose -p drive up -d --wait
 Container drive-nc-db-1  Healthy
 Container drive-app-1    Started
$ docker compose -p drive exec -u www-data app php occ status
  - installed: true
  - versionstring: 30.0.1
$ curl -s -u admin:change-me-admin -T report.pdf http://localhost:8080/remote.php/dav/files/admin/report.pdf -o /dev/null -w '%{http_code}\n'
201

Expected result. A private drive. Files are uploaded over WebDAV and stored in the nc-html volume.

Example 4: Uptime Kuma and one nginx in front of everything

Goal. Uptime Kuma and one nginx in front of everything.

compose.yaml (all tools behind nginx)

services:
  proxy:
    image: nginx:1.27-alpine
    ports: ["80:80"]
    volumes: ["./proxy.conf:/etc/nginx/conf.d/default.conf:ro"]
    networks: [edge]
    restart: unless-stopped
  kuma:
    image: louislam/uptime-kuma:1
    volumes: [kuma-data:/app/data]
    networks: [edge]
    restart: unless-stopped
networks:
  edge:
    name: edge
volumes:
  kuma-data:

proxy.conf

server {
    listen 80;
    server_name git.lab.local;
    location / { proxy_pass http://gitea:3000; proxy_set_header Host $host; }
}
server {
    listen 80;
    server_name drive.lab.local;
    client_max_body_size 2G;
    location / { proxy_pass http://app:80; proxy_set_header Host $host; }
}
server {
    listen 80;
    server_name status.lab.local;
    location / {
        proxy_pass http://kuma:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

connect the other stacks to edge

docker network connect edge gitea-gitea-1 --alias gitea
docker network connect edge drive-app-1 --alias app
echo '127.0.0.1 git.lab.local drive.lab.local status.lab.local' | sudo tee -a /etc/hosts

What each line does

  • networks: edge, name: edge — One shared network with a fixed name, so other stacks can join it.
  • server_name — nginx picks the backend by the host name the browser asked for.
  • client_max_body_size 2G — Allows big file uploads to Nextcloud.
  • Upgrade headers — Uptime Kuma uses WebSockets; these headers let them pass through nginx.
  • docker network connect --alias — Adds a running container to the edge network with a short name.
  • /etc/hosts — Points the lab names to this machine. In a company you add DNS records instead.

Green output

$ docker compose -p edge up -d
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: git.lab.local' localhost
200
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: drive.lab.local' localhost/status.php
200
# Browser: http://status.lab.local > create admin > Add monitor:
#   HTTP  http://gitea:3000   every 60 s
#   HTTP  http://app:80/status.php
#   TCP   gitea-db:5432
# Dashboard: 3 monitors, all green (Up)

Expected result. All tools open on port 80 by name, and Uptime Kuma watches them from inside the edge network.

Every command, step by step (plain docker and Compose)

  1. Make folders

    mkdir -p selfhost/gitea selfhost/nextcloud && cd selfhost
    

    What it does: One folder per stack plus the proxy files at the top.

    (no output)
    
  2. Start Portainer

    docker run -d --name portainer -p 127.0.0.1:9443:9443 -v /var/run/docker.sock:/var/run/docker.sock -v portainer-data:/data portainer/portainer-ce:2.21.4
    

    What it does: Docker UI on 9443.

    a8c1f9d0e2b3
    
  3. Start Gitea

    docker compose -f gitea/compose.yaml -p gitea up -d --wait
    

    What it does: Gitea and its Postgres.

    Container gitea-gitea-1 Started
    
  4. Start Nextcloud

    docker compose -f nextcloud/compose.yaml -p drive up -d --wait
    

    What it does: Nextcloud, MariaDB, Redis and cron.

    Container drive-app-1 Started
    
  5. Check Nextcloud install

    docker compose -p drive exec -u www-data app php occ status
    

    What it does: Runs the Nextcloud admin tool inside the container.

    installed: true
    
  6. Start proxy and Kuma

    docker compose -p edge up -d
    

    What it does: nginx on 80 and Uptime Kuma.

    Container edge-proxy-1 Started
    
  7. Join stacks to edge

    docker network connect edge gitea-gitea-1 --alias gitea
    docker network connect edge drive-app-1 --alias app
    

    What it does: Lets nginx reach Gitea and Nextcloud by name.

    (no output)
    
  8. Test names

    curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: git.lab.local' localhost
    

    What it does: nginx forwards to Gitea.

    200
    
  9. See all stacks

    docker compose ls
    

    What it does: Lists gitea, drive and edge.

    NAME    STATUS
    drive   running(4)
    edge    running(2)
    gitea   running(2)
    
  10. Back up volumes

    for v in gitea_gitea-data gitea_gitea-db drive_nc-html drive_nc-db edge_kuma-data; do docker run --rm -v $v:/data:ro -v "$PWD":/backup alpine tar czf /backup/$v.tgz -C /data .; done
    

    What it does: Saves every volume to a tgz in the current folder.

    ls *.tgz
    drive_nc-db.tgz drive_nc-html.tgz edge_kuma-data.tgz gitea_gitea-data.tgz gitea_gitea-db.tgz
    
  11. Clean up

    docker compose -p edge down -v
    docker compose -f nextcloud/compose.yaml -p drive down -v
    docker compose -f gitea/compose.yaml -p gitea down -v
    docker rm -f portainer && docker volume rm portainer-data
    

    What it does: Removes all stacks and their data.

    Network edge Removed
    

When it is red 1

Access through untrusted domain

Cause. Nextcloud was opened with a host name not in trusted_domains.

Fix, step by step

  1. Add the name to NEXTCLOUD_TRUSTED_DOMAINS before first start.
  2. Or run: docker compose -p drive exec -u www-data app php occ config:system:set trusted_domains 2 --value=drive.lab.local

When it is red 2

nginx: [emerg] host not found in upstream "gitea:3000"

Cause. nginx started before gitea joined the edge network.

Fix, step by step

  1. Connect the container with docker network connect edge ... --alias gitea.
  2. Restart the proxy: docker compose -p edge restart proxy.

When it is red 3

Your Portainer instance timed out for security purposes.

Cause. No admin user was created within 5 minutes of the first start.

Fix, step by step

  1. docker restart portainer.
  2. Open the UI and create the admin at once.

When it is red 4

413 Request Entity Too Large

Cause. nginx blocks big uploads by default (1 MB).

Fix, step by step

  1. Set client_max_body_size 2G in the drive server block.
  2. Reload: docker compose -p edge exec proxy nginx -s reload.

When it is red 5

ssh: connect to host localhost port 22: Connection refused (git clone over ssh)

Cause. Gitea SSH is on host port 2222.

Fix, step by step

  1. Use ssh://git@localhost:2222/ravi/demo.git.
  2. Set GITEA__server__SSH_PORT=2222 so Gitea shows the right clone URL.

Practice tasks

Easy Task 1: Portainer

Why: UI.

What to do: Run Portainer and find the logs of any container in it.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker run -d --name portainer -p 127.0.0.1:9443:9443 -v /var/run/docker.sock:/var/run/docker.sock -v portainer-data:/data portainer/portainer-ce:2.21.4

Easy Task 2: Gitea

Why: Git server.

What to do: Run Gitea and push one repo.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -f gitea/compose.yaml -p gitea up -d --wait
git push http://localhost:3000/ravi/demo.git main

Easy Task 3: Nextcloud

Why: Drive.

What to do: Upload a file with curl over WebDAV.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
curl -u admin:change-me-admin -T notes.txt http://localhost:8080/remote.php/dav/files/admin/notes.txt

Medium Task 4: Proxy

Why: Host names.

What to do: Reach all three tools by host name on port 80.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: status.lab.local' localhost

Medium Task 5: Monitor

Why: Alerts.

What to do: Add three monitors in Uptime Kuma and stop Gitea.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -p gitea stop gitea
# Kuma shows gitea Down within 60 seconds

Medium Task 6: occ

Why: Admin tool.

What to do: List Nextcloud users with occ.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -p drive exec -u www-data app php occ user:list

Medium Task 7: Backup

Why: Safety.

What to do: Back up gitea-data to a tgz.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker run --rm -v gitea_gitea-data:/data:ro -v "$PWD":/backup alpine tar czf /backup/gitea-data.tgz -C /data .

Hard Task 8: Restore

Why: Disaster.

What to do: Delete gitea-data and restore it from the tgz.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -p gitea down
docker volume rm gitea_gitea-data
docker volume create gitea_gitea-data
docker run --rm -v gitea_gitea-data:/data -v "$PWD":/backup alpine tar xzf /backup/gitea-data.tgz -C /data
docker compose -f gitea/compose.yaml -p gitea up -d

Hard Task 9: Update

Why: Upgrades.

What to do: Upgrade Gitea from 1.22 to a newer tag after a backup.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
sed -i 's/gitea:1.22/gitea:1.23/' gitea/compose.yaml
docker compose -f gitea/compose.yaml -p gitea pull
docker compose -f gitea/compose.yaml -p gitea up -d

Easy Task 10: Clean

Why: Habit.

What to do: Remove everything.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the selfhost folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -p edge down -v
docker compose -f nextcloud/compose.yaml -p drive down -v
docker compose -f gitea/compose.yaml -p gitea down -v
docker rm -f portainer

Homework: try at home

Add HTTPS to all three host names with a self-signed certificate (or Let's Encrypt on a real domain), and set up an Uptime Kuma alert to a Telegram or email channel when Gitea goes down; prove it by stopping gitea.

Recap

  • Each tool is one image, often with its own database container.
  • Settings come from environment variables, not hand-edited config files.
  • One nginx on port 80 routes by host name.
  • A named shared network joins separate Compose projects.
  • docker.sock access is root access: protect Portainer.
  • Back up every volume before an upgrade.

FAQ

Q. Can I use Traefik instead of nginx?

Yes. Traefik reads Docker labels and gets certificates automatically. Project 58.7 shows it.

Q. Is Gitea like GitLab?

Similar idea but much lighter. GitLab needs about 4 GB RAM; Gitea runs in about 150 MB.

Q. Where are Nextcloud files stored?

In the nc-html volume under data/. For big drives, mount a separate disk there.

Q. Can Portainer manage other servers?

Yes, with the Portainer Agent container on each server.

Q. Is louislam/uptime-kuma an official image?

It is the image published by the Uptime Kuma project itself on Docker Hub.

Q. How much RAM do all four need?

About 1.5 GB in a small lab.

Interview questions

  1. How would you host several internal web apps on one server?

    Model answer: Run each in containers, put a reverse proxy (nginx or Traefik) in front, route by host name, and add TLS.

  2. What are the risks of giving a container the Docker socket?

    Model answer: It can start privileged containers and mount the host disk, so it equals root; limit it, or use a socket proxy.

  3. How do you upgrade a self-hosted app in Docker safely?

    Model answer: Back up volumes, read release notes, pin the new tag, pull, up -d, check health, roll back to the old tag if needed.

  4. How do containers from different Compose projects communicate?

    Model answer: Through a shared external network, using service names or aliases.

  5. Why put admin UIs on 127.0.0.1?

    Model answer: So they are reachable only through SSH tunnels or a VPN, not from the internet.

Quick quiz

Q1. Why is mounting docker.sock risky?

It gives the container full control of Docker, which is the same as root on the host.

Q2. How does one nginx serve many tools on port 80?

It uses server_name to choose the backend by the requested host name.

Q3. How do separate Compose projects talk to each other?

They join a shared, named network such as edge.

Q4. Why does Nextcloud need a cron container?

Background jobs like cleanup and previews must run every 5 minutes.

Q5. How do you back up a named volume?

Mount it read-only in a throwaway alpine container and tar it to a host folder.

Samajla ka?

Ravi Sir's Cards for this lesson

मराठी cards