--- title: "How to Host Multiple Dev Projects on One Ubuntu Server" description: "Run several development projects on one Ubuntu 24.04 server with Docker Compose, Nginx subdomains, HTTP basic auth and free Let's Encrypt certificates." category: "Use Cases" language: en source: https://cubepath.com/docs/use-cases/development-server-with-multiple-projects updated: 2026-09-25 --- # How to Host Multiple Dev Projects on One Ubuntu Server A shared development server lets you and your team preview several projects at once, each on its own subdomain, without every project fighting over the same PHP, Node.js or database version. In this tutorial you will build that setup on Ubuntu 24.04: each project runs in its own Docker Compose stack bound to a local port, and Nginx on the host routes `project.dev.example.com` to the right stack, adds HTTPS with Let's Encrypt and protects everything with a password. As a worked example you will deploy two projects with very different stacks: a small Node.js application and a WordPress site with its own MariaDB database. ## Prerequisites To follow this tutorial you need: - A server running Ubuntu 24.04 LTS with at least 2 GB of RAM (4 GB or more if you plan to run several databases at once), for example a CubePath VPS. - A non-root user with `sudo` privileges. - A domain you control. This guide uses **`dev.example.com`**; replace it with **`your_domain`**. - A wildcard DNS record `*.dev.example.com` of type A pointing to **`your_server_ip`**, so every new project subdomain resolves without extra DNS changes. - UFW enabled with SSH allowed (`sudo ufw allow OpenSSH` followed by `sudo ufw enable`). ## Step 1 - Installing Docker Engine and Docker Compose Docker keeps each project's runtime and dependencies isolated, so a project on Node.js 22 and another on PHP 8.3 can live side by side. Install Docker Engine from Docker's official repository to get current versions and the Compose plugin. Add Docker's signing key: ```bash sudo apt update sudo apt install ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc ``` Add the repository: ```bash echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null ``` Install Docker Engine and the plugins: ```bash sudo apt update sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` Let your user run Docker without `sudo`, then log out and back in so the new group applies: ```bash sudo usermod -aG docker $USER ``` > Warning: Membership in the `docker` group is equivalent to root access on the server. Only add trusted developers to it. After logging back in, verify the installation: ```bash docker run --rm hello-world docker compose version ``` ```output Hello from Docker! This message shows that your installation appears to be working correctly. ... Docker Compose version v2.x.x ``` ## Step 2 - Preparing a shared project directory Keep every project under a single directory so it is easy to find, back up and share with your team. Create `/srv/projects` owned by a `developers` group, with the setgid bit so new files inherit the group: ```bash sudo groupadd developers sudo usermod -aG developers $USER sudo mkdir -p /srv/projects sudo chown root:developers /srv/projects sudo chmod 2775 /srv/projects ``` Log out and back in again, then confirm that you can write there: ```bash touch /srv/projects/test && ls -l /srv/projects && rm /srv/projects/test ``` ```output -rw-rw-r-- 1 your_user developers 0 Sep 25 10:00 test ``` Each project will get its own local port. Keep a simple table in your notes or a `README` in `/srv/projects`: | Project | Subdomain | Local port | |---|---|---| | nodeapp | nodeapp.dev.example.com | 3001 | | wordpress | wp.dev.example.com | 3002 | ## Step 3 - Deploying a Node.js project Create the project directory: ```bash mkdir -p /srv/projects/nodeapp cd /srv/projects/nodeapp ``` Create a minimal application. In a real project this is your repository, cloned with `git clone`: ```bash nano server.js ``` ```javascript const http = require("http"); const port = process.env.PORT || 3000; http .createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end(`Hello from nodeapp, running Node.js ${process.version}\n`); }) .listen(port, () => console.log(`Listening on port ${port}`)); ``` Add a `Dockerfile` that builds the image: ```bash nano Dockerfile ``` ```dockerfile FROM node:22-alpine WORKDIR /app COPY server.js . ENV PORT=3000 EXPOSE 3000 USER node CMD ["node", "server.js"] ``` Describe the stack in `compose.yaml`: ```bash nano compose.yaml ``` ```yaml services: app: build: . restart: unless-stopped ports: - "127.0.0.1:3001:3000" ``` The port mapping `127.0.0.1:3001:3000` is the key to this whole setup. Docker publishes ports by writing its own firewall rules, which bypass UFW: a plain `3001:3000` mapping would expose the container to the whole Internet even if UFW blocks port 3001. Binding to `127.0.0.1` makes the container reachable only from the server itself, so Nginx becomes the only way in. Build and start the project: ```bash docker compose up -d --build ``` Test it locally: ```bash curl http://127.0.0.1:3001 ``` ```output Hello from nodeapp, running Node.js v22.x.x ``` ## Step 4 - Deploying a WordPress project with its own database The second project uses a completely different stack. It gets its own MariaDB container, its own Docker network (Compose creates one per project) and its own volumes, so it cannot interfere with other projects' data. ```bash mkdir -p /srv/projects/wordpress cd /srv/projects/wordpress ``` Store credentials in an `.env` file that Compose reads automatically. Replace the placeholders with long random passwords, for example generated with `openssl rand -base64 24`: ```bash nano .env ``` ```ini DB_NAME=wordpress DB_USER=wordpress DB_PASSWORD=your_strong_password ``` Restrict the file to the owner and group: ```bash chmod 660 .env ``` Create the Compose file: ```bash nano compose.yaml ``` ```yaml services: db: image: mariadb:11 restart: unless-stopped environment: MARIADB_DATABASE: ${DB_NAME} MARIADB_USER: ${DB_USER} MARIADB_PASSWORD: ${DB_PASSWORD} MARIADB_RANDOM_ROOT_PASSWORD: "1" volumes: - db_data:/var/lib/mysql wordpress: image: wordpress:latest restart: unless-stopped depends_on: - db environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_NAME: ${DB_NAME} WORDPRESS_DB_USER: ${DB_USER} WORDPRESS_DB_PASSWORD: ${DB_PASSWORD} ports: - "127.0.0.1:3002:80" volumes: - wp_data:/var/www/html volumes: db_data: wp_data: ``` The database has no `ports` entry at all: only the `wordpress` container can reach it over the project's private network. Start the stack and check that both containers are running: ```bash docker compose up -d docker compose ps ``` ```output NAME IMAGE SERVICE STATUS PORTS wordpress-db-1 mariadb:11 db Up 20 seconds 3306/tcp wordpress-wordpress-1 wordpress:latest wordpress Up 19 seconds 127.0.0.1:3002->80/tcp ``` ```bash curl -I http://127.0.0.1:3002 ``` ```output HTTP/1.1 302 Found Location: http://127.0.0.1:3002/wp-admin/install.php ``` The redirect to the installer confirms that WordPress reached the database. Do not run the installer yet; you will do it through the final HTTPS address. ## Step 5 - Installing Nginx as the reverse proxy Nginx listens on ports 80 and 443 and forwards each subdomain to the matching local port. Install it together with `apache2-utils`, which provides the `htpasswd` tool: ```bash sudo apt install nginx apache2-utils ``` Allow HTTP and HTTPS through UFW: ```bash sudo ufw allow "Nginx Full" ``` Development sites should not be public. Create a password file with a first user; you will be asked for a password: ```bash sudo htpasswd -c /etc/nginx/.htpasswd your_user ``` To add more developers later, run the same command without `-c`, which would overwrite the file. ## Step 6 - Creating a server block for each project Create a server block for the Node.js project: ```bash sudo nano /etc/nginx/sites-available/nodeapp.dev.example.com ``` ```nginx server { listen 80; listen [::]:80; server_name nodeapp.dev.example.com; auth_basic "Development"; auth_basic_user_file /etc/nginx/.htpasswd; location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } ``` The `Upgrade` and `Connection` headers allow WebSocket connections, which many dev servers use for hot reloading. Create the block for WordPress. It is identical except for the name, the port and a larger upload limit for media and plugins: ```bash sudo nano /etc/nginx/sites-available/wp.dev.example.com ``` ```nginx server { listen 80; listen [::]:80; server_name wp.dev.example.com; client_max_body_size 64m; auth_basic "Development"; auth_basic_user_file /etc/nginx/.htpasswd; location / { proxy_pass http://127.0.0.1:3002; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` The official WordPress image reads `X-Forwarded-Proto`, so WordPress knows it is served over HTTPS once you add certificates. Enable both sites, remove the default site, test the configuration and reload Nginx: ```bash sudo ln -s /etc/nginx/sites-available/nodeapp.dev.example.com /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/wp.dev.example.com /etc/nginx/sites-enabled/ sudo rm /etc/nginx/sites-enabled/default sudo nginx -t sudo systemctl reload nginx ``` ```output nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful ``` Check that the password is enforced: ```bash curl -I http://nodeapp.dev.example.com curl -u your_user http://nodeapp.dev.example.com ``` ```output HTTP/1.1 401 Unauthorized ... Hello from nodeapp, running Node.js v22.x.x ``` ## Step 7 - Adding HTTPS with Let's Encrypt Certbot requests free certificates from Let's Encrypt and updates the Nginx server blocks for you. Install it with the Nginx plugin: ```bash sudo apt install certbot python3-certbot-nginx ``` Request certificates for both subdomains: ```bash sudo certbot --nginx -d nodeapp.dev.example.com -d wp.dev.example.com ``` Enter an email address for expiry notices and accept the terms. Certbot adds the `listen 443 ssl` configuration and a redirect from HTTP to HTTPS in each file. The Ubuntu package installs a systemd timer that renews certificates automatically. Test renewal: ```bash sudo certbot renew --dry-run ``` ```output Congratulations, all simulated renewals succeeded: ``` Now open `https://wp.dev.example.com` in a browser, log in with your basic auth credentials and complete the WordPress installer. ## Step 8 - Adding a new project With this structure in place, every new project follows the same routine: 1. Create `/srv/projects/` with a `compose.yaml` that publishes a free port on `127.0.0.1` only. 2. Run `docker compose up -d` and test with `curl http://127.0.0.1:`. 3. Copy an existing server block in `/etc/nginx/sites-available/`, change `server_name` and the port, enable it and run `sudo nginx -t && sudo systemctl reload nginx`. 4. Run `sudo certbot --nginx -d .dev.example.com`. The wildcard DNS record means no DNS change is needed. To see what is running and how much each project uses: ```bash docker compose ls docker stats --no-stream ``` ```output NAME STATUS CONFIG FILES nodeapp running(1) /srv/projects/nodeapp/compose.yaml wordpress running(2) /srv/projects/wordpress/compose.yaml ``` To stop a project you are not working on and free its memory, run `docker compose stop` in its directory. ## Step 9 - Limiting container log size By default Docker keeps container logs without a size limit, which can fill the disk on a server with many projects. Set a limit for all containers: ```bash sudo nano /etc/docker/daemon.json ``` ```json { "log-driver": "local", "log-opts": { "max-size": "10m", "max-file": "3" } } ``` Restart Docker. The new settings apply to containers created from now on, so recreate the projects: ```bash sudo systemctl restart docker cd /srv/projects/nodeapp && docker compose up -d --force-recreate cd /srv/projects/wordpress && docker compose up -d --force-recreate ``` `docker compose logs -f` in a project directory still shows its logs as before. ## Troubleshooting **Nginx returns `502 Bad Gateway`.** The container behind that subdomain is not running or listens on a different port. Run `docker compose ps` in the project directory and `curl http://127.0.0.1:`, and read `sudo tail /var/log/nginx/error.log`. **Certbot fails with a DNS or connection error.** Check that the subdomain resolves to your server with `dig +short nodeapp.dev.example.com` and that port 80 is open with `sudo ufw status`. **WordPress loops between HTTP and HTTPS.** Make sure the server block sends `proxy_set_header X-Forwarded-Proto $scheme;` and reload Nginx. **A port is already in use when starting a stack.** Another project already publishes that port. List published ports with `sudo ss -tlnp | grep 127.0.0.1` and choose a free one. ## Conclusion You now have a development server where each project runs in its own isolated Docker Compose stack, reachable on its own HTTPS subdomain behind a shared password. Useful next steps are backing up the `/srv/projects` directory and the Docker volumes with `docker compose exec db mariadb-dump` or a similar dump for each database, deploying automatically from Git with a CI job that runs `docker compose up -d --build` over SSH, and replacing basic auth with an SSO proxy if your team grows.