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
sudoprivileges. - A domain you control. This guide uses
dev.example.com; replace it withyour_domain. - A wildcard DNS record
*.dev.example.comof type A pointing toyour_server_ip, so every new project subdomain resolves without extra DNS changes. - UFW enabled with SSH allowed (
sudo ufw allow OpenSSHfollowed bysudo 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:
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:
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:
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:
sudo usermod -aG docker $USER
WarningMembership in the
dockergroup is equivalent to root access on the server. Only add trusted developers to it.
After logging back in, verify the installation:
docker run --rm hello-world
docker compose version
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:
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:
touch /srv/projects/test && ls -l /srv/projects && rm /srv/projects/test
-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:
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:
nano server.js
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:
nano 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:
nano compose.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:
docker compose up -d --build
Test it locally:
curl http://127.0.0.1:3001
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.
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:
nano .env
DB_NAME=wordpress
DB_USER=wordpress
DB_PASSWORD=your_strong_password
Restrict the file to the owner and group:
chmod 660 .env
Create the Compose file:
nano compose.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:
docker compose up -d
docker compose ps
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
curl -I http://127.0.0.1:3002
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:
sudo apt install nginx apache2-utils
Allow HTTP and HTTPS through UFW:
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:
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:
sudo nano /etc/nginx/sites-available/nodeapp.dev.example.com
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:
sudo nano /etc/nginx/sites-available/wp.dev.example.com
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:
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
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:
curl -I http://nodeapp.dev.example.com
curl -u your_user http://nodeapp.dev.example.com
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:
sudo apt install certbot python3-certbot-nginx
Request certificates for both subdomains:
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:
sudo certbot renew --dry-run
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:
- Create
/srv/projects/<name>with acompose.yamlthat publishes a free port on127.0.0.1only. - Run
docker compose up -dand test withcurl http://127.0.0.1:<port>. - Copy an existing server block in
/etc/nginx/sites-available/, changeserver_nameand the port, enable it and runsudo nginx -t && sudo systemctl reload nginx. - Run
sudo certbot --nginx -d <name>.dev.example.com.
The wildcard DNS record means no DNS change is needed.
To see what is running and how much each project uses:
docker compose ls
docker stats --no-stream
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:
sudo nano /etc/docker/daemon.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:
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:<port>, 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.
