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.
How: step by step
- Run Portainer alone. Manage Docker from the browser.
- Run Gitea with Postgres. Push a repo over HTTP.
- Run Nextcloud with MariaDB and Redis. Upload a file.
- Run Uptime Kuma. Monitor the other three.
- Put all behind one nginx. Host names on port 80.
- Back up every volume. tar from a throwaway container.
- Clean up. down -v.
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)
-
Make folders
mkdir -p selfhost/gitea selfhost/nextcloud && cd selfhostWhat it does: One folder per stack plus the proxy files at the top.
(no output) -
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.4What it does: Docker UI on 9443.
a8c1f9d0e2b3 -
Start Gitea
docker compose -f gitea/compose.yaml -p gitea up -d --waitWhat it does: Gitea and its Postgres.
Container gitea-gitea-1 Started -
Start Nextcloud
docker compose -f nextcloud/compose.yaml -p drive up -d --waitWhat it does: Nextcloud, MariaDB, Redis and cron.
Container drive-app-1 Started -
Check Nextcloud install
docker compose -p drive exec -u www-data app php occ statusWhat it does: Runs the Nextcloud admin tool inside the container.
installed: true -
Start proxy and Kuma
docker compose -p edge up -dWhat it does: nginx on 80 and Uptime Kuma.
Container edge-proxy-1 Started -
Join stacks to edge
docker network connect edge gitea-gitea-1 --alias gitea docker network connect edge drive-app-1 --alias appWhat it does: Lets nginx reach Gitea and Nextcloud by name.
(no output) -
Test names
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: git.lab.local' localhostWhat it does: nginx forwards to Gitea.
200 -
See all stacks
docker compose lsWhat it does: Lists gitea, drive and edge.
NAME STATUS drive running(4) edge running(2) gitea running(2) -
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 .; doneWhat 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 -
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-dataWhat 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
- Add the name to NEXTCLOUD_TRUSTED_DOMAINS before first start.
- 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
- Connect the container with docker network connect edge ... --alias gitea.
- 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
- docker restart portainer.
- 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
- Set client_max_body_size 2G in the drive server block.
- 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
- Use ssh://git@localhost:2222/ravi/demo.git.
- 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.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- 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.4Easy Task 2: Gitea
Why: Git server.
What to do: Run Gitea and push one repo.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- 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 mainEasy Task 3: Nextcloud
Why: Drive.
What to do: Upload a file with curl over WebDAV.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- 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.txtMedium Task 4: Proxy
Why: Host names.
What to do: Reach all three tools by host name on port 80.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: status.lab.local' localhostMedium Task 5: Monitor
Why: Alerts.
What to do: Add three monitors in Uptime Kuma and stop Gitea.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -p gitea stop gitea
# Kuma shows gitea Down within 60 secondsMedium Task 6: occ
Why: Admin tool.
What to do: List Nextcloud users with occ.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -p drive exec -u www-data app php occ user:listMedium Task 7: Backup
Why: Safety.
What to do: Back up gitea-data to a tgz.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- 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.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- 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 -dHard Task 9: Update
Why: Upgrades.
What to do: Upgrade Gitea from 1.22 to a newer tag after a backup.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- 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 -dEasy Task 10: Clean
Why: Habit.
What to do: Remove everything.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the selfhost folder with the files from this lesson.
- 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 portainerHomework: 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
-
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.
-
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.
-
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.
-
How do containers from different Compose projects communicate?
Model answer: Through a shared external network, using service names or aliases.
-
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?
Revision card
Interview card