If you need to host a few websites, dashboards, APIs or internal tools, it is tempting to create a new VM for every app.
That works for a while. Then the estate becomes hard to track: too many servers, too many certificates, too many places to check logs, and no consistent deployment pattern.
This guide shows a simpler middle ground:
One Ubuntu VM.
Multiple Docker apps.
One reverse proxy.
One app drive.
Controlled access through Azure.
It is not Kubernetes. It is not trying to be Azure App Service or Azure Container Apps. It is a practical single-server hosting pattern for lightweight workloads that still need isolation, HTTPS, and a clean operational model.
The basic idea
The platform uses one secured Ubuntu VM as a shared Docker host.
Each application gets its own folder and its own docker-compose.yml. The app runs in its own container or Compose stack. Public traffic reaches the VM through Azure network controls, then Nginx Proxy Manager routes each domain to the correct container.
Typical apps might include:
- Static HTML/CSS/JavaScript sites
- React build output
- Node.js apps
- Python APIs
- Streamlit or Dash dashboards
- Internal tools
- Lightweight prototypes
The standard pattern is:
domain -> Azure network rules -> Nginx Proxy Manager -> Docker container
Why this is useful
The value is not that Docker is fashionable. The value is operational simplicity.
Instead of giving every small app its own VM, you can run several isolated apps on one host while keeping a predictable folder structure and a single HTTPS entry point.
Each app has:
- Its own folder
- Its own Compose file
- Its own container name
- Its own logs or data folders, where needed
- Its own proxy host mapping
For example:
nicelink.company.com -> nicelink-web:80
dashboard.company.com -> dashboard-web:3000
api.company.com -> api-service:8000
Nginx Proxy Manager decides where traffic goes based on the domain name. The app containers do not normally expose their own public ports.
How traffic flows
At a high level, the flow looks like this:
User / Browser
-> DNS record
-> Azure Firewall or NSG
-> Ubuntu Docker VM
-> Nginx Proxy Manager
-> Docker proxy network
-> App container
The common exposed ports are:
| Port | Purpose | Exposure |
|---|---|---|
22 | SSH administration | Restricted to admin or operator IPs |
80 | HTTP and certificate validation | Public only when needed |
443 | HTTPS application traffic | Public |
81 | Nginx Proxy Manager UI | Restricted to trusted IPs |
Port 81 should not be casually public. It is an admin interface and should be treated like one.
The main building blocks
The platform is made from a small number of boring components, which is part of the point.
| Component | Role |
|---|---|
| Azure Firewall / NSG | Controls inbound access before traffic reaches the VM |
| Ubuntu Server | The base operating system |
| Docker Engine | Runs the application containers |
| Docker Compose | Defines each app stack |
| Nginx Proxy Manager | Routes domains and manages SSL certificates |
/appdrive | Stores app and proxy data away from the OS disk |
| Fail2ban | Helps reduce repeated SSH login attempts |
| SSH keys and Linux groups | Control operator access |
This is enough for many small and medium internal workloads without introducing a full orchestration layer.
Keep apps out of home folders
One important design choice is to keep application files away from user home directories.
Do not make production apps live here:
/home/<username>
Use a dedicated application drive instead:
/appdrive
A simple structure looks like this:
/appdrive
|-- docker
| |-- apps
| | `-- nicelink
| `-- proxy
| `-- nginx-proxy-manager
|-- logs
`-- transfer
Application folders then live under:
/appdrive/docker/apps/<app-name>
This makes the platform easier to back up, review, move, and troubleshoot.
How one app is normally laid out
For a static or React app, the folder may be very small:
/appdrive/docker/apps/my-static-app
|-- docker-compose.yml
|-- html
`-- logs
For a Node.js app, the folder may include source code and a Dockerfile:
/appdrive/docker/apps/my-node-app
|-- docker-compose.yml
|-- Dockerfile
|-- package.json
`-- src
For a Python app:
/appdrive/docker/apps/my-python-app
|-- docker-compose.yml
|-- Dockerfile
|-- requirements.txt
`-- app
The goal is that each app is self-contained. You should be able to open the app folder and understand how that app is started, logged, rebuilt and proxied.
The shared Docker proxy network
Public-facing apps join a shared Docker network, usually called:
proxy
Nginx Proxy Manager also joins that network. That allows it to reach app containers by container name.
Example:
Public request:
https://nicelink.company.com
Proxy forwards to:
nicelink-web:80
That means nicelink-web does not need to publish port 80 to the internet. It only needs to be reachable inside Docker.
Nginx Proxy Manager setup
Nginx Proxy Manager normally exposes:
80 public HTTP
443 public HTTPS
81 admin UI
A basic Compose file looks like this:
services:
nginx-proxy-manager:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "81:81"
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- proxy
networks:
proxy:
external: true
Store the proxy stack somewhere predictable:
/appdrive/docker/proxy/nginx-proxy-manager
The important folders are:
/appdrive/docker/proxy/nginx-proxy-manager/data
/appdrive/docker/proxy/nginx-proxy-manager/letsencrypt
Those hold proxy configuration and certificate data. They should be included in any backup plan.
Adding a new app
The operational flow for onboarding a new app should be simple:
1. Create the app folder under /appdrive/docker/apps.
2. Add docker-compose.yml.
3. Start the container or stack.
4. Make sure it joins the proxy network.
5. Point DNS to the VM public IP.
6. Add a Proxy Host in Nginx Proxy Manager.
7. Request an SSL certificate.
8. Test HTTPS access.
In Nginx Proxy Manager, the proxy host usually looks like this:
Domain Names: app.company.com
Scheme: http
Forward Hostname/IP: container-name
Forward Port: internal-container-port
Example:
Domain Names: nicelink.company.com
Scheme: http
Forward Hostname/IP: nicelink-web
Forward Port: 80
Recommended options:
Block Common Exploits: enabled
Websockets Support: enabled only when needed
Force SSL: enabled after certificate issue
HTTP/2 Support: enabled
Daily commands operators need
Most operations are ordinary Docker Compose commands.
Start an app:
cd /appdrive/docker/apps/<app-name>
docker compose up -d
Restart an app:
docker compose restart
Rebuild an app:
docker compose up -d --build
View logs:
docker compose logs -f
Restart Nginx Proxy Manager:
cd /appdrive/docker/proxy/nginx-proxy-manager
docker compose restart
Keep the commands boring. The more predictable this platform is, the easier it is for another operator to support it later.
Security model
The platform uses layered security. Azure controls what reaches the VM, Linux controls who can log in and operate the host, and Docker keeps app processes separated.
Recommended exposure:
| Area | Recommendation |
|---|---|
| SSH | Restrict to trusted IPs and use SSH keys |
| NPM admin UI | Restrict port 81 to operators |
| App traffic | Use HTTPS on port 443 |
| App containers | Avoid direct public port exposure |
| Docker access | Give only to trusted operators |
Linux groups can separate responsibilities:
infra = infrastructure administrators
dev = app file access
docker = Docker operations
sudo = full server administration
Remember that membership in the docker group is powerful. A user who can control Docker can usually do much more than just restart an app.
Fail2ban is also useful for SSH hardening:
sudo systemctl status fail2ban
sudo fail2ban-client status
sudo fail2ban-client status sshd
Weekly checks
The platform does not need a heavy process, but it does need regular checks.
A practical weekly routine:
docker ps
df -h
df -h /appdrive
docker system df
sudo fail2ban-client status
Check the proxy stack:
cd /appdrive/docker/proxy/nginx-proxy-manager
docker compose ps
Check important app stacks:
cd /appdrive/docker/apps/<app-name>
docker compose ps
Review certificates in Nginx Proxy Manager as well. Look for failed renewals, expired certificates and old proxy hosts that no longer need to exist.
Do not aggressively run:
docker system prune
That command can remove images, stopped containers and other resources. Use it only when you understand what will be removed.
Nginx Proxy Manager vs raw Nginx
Nginx Proxy Manager is the platform standard here, but raw Nginx is also a valid architectural option.
The reason for choosing Nginx Proxy Manager is operational speed. In a shared hosting environment, new domains, certificates and proxy hosts may need to be added regularly. A UI makes that easier for a small team.
Nginx Proxy Manager is useful when the model is:
One VM.
Multiple Docker apps.
Multiple domains or subdomains.
One reverse proxy.
Simple HTTPS management.
Why Nginx Proxy Manager works well here
| Reason | Benefit |
|---|---|
| Automated SSL | Certificates can be requested and renewed through the UI |
| Faster operations | New proxy hosts can be added quickly |
| Lower barrier | Operators do not need deep Nginx knowledge for standard routing |
| Centralised management | Proxy hosts, certificates and routing live in one place |
| Frequent changes | Good fit for prototypes, dashboards and internal tools |
When raw Nginx may be better
Raw Nginx may be preferred when configuration discipline matters more than speed.
| Scenario | Why raw Nginx may fit better |
|---|---|
| GitOps / Infrastructure as Code | Nginx config can be version-controlled in Git |
| Complex routing | Better for advanced rewrites, headers and routing rules |
| Strict disaster recovery | Config can be restored exactly from source control |
| Minimal footprint | A raw Nginx container is lighter |
| Controlled production | Changes can go through reviewed config files |
Example cases:
- Complex path-based routing
- Advanced API gateway-style routing
- Header rewrites
- Strict configuration-as-code requirements
- Fully automated CI/CD infrastructure deployments
The trade-off is simple:
Nginx Proxy Manager gives speed, accessibility and certificate automation. Raw Nginx gives deeper control and better version-control discipline, but requires stronger Nginx knowledge.
For this platform, the business value is fast and secure onboarding of multiple small apps. That is why Nginx Proxy Manager is the preferred standard.
Limitations to be honest about
This setup is useful, but it has limits.
It is not designed to replace Kubernetes, Azure App Service, Azure Container Apps or a full high-availability platform.
Current limitations include:
- Single VM dependency
- Manual scaling
- Manual backup process unless automated later
- Docker group users are trusted operators
- No built-in multi-node failover
- No automatic container health remediation beyond Docker restart policies
- No built-in centralised monitoring or alerting yet
- Nginx Proxy Manager configuration is stored locally, not fully Git-versioned
The main risks are predictable:
| Risk | Mitigation |
|---|---|
| Single server failure | Add backup and recovery process |
| Disk growth | Monitor /appdrive and Docker usage |
| Docker access power | Limit Docker access to trusted users |
| Proxy misconfiguration | Document and review critical route changes |
| Certificate renewal failure | Check NPM certificate status regularly |
| Manual deployment error | Introduce CI/CD when needed |
What to improve later
Future improvements may include:
- Automated backups for /appdrive
- Backup and restore runbook
- Monitoring and alerting
- Centralised logging
- CI/CD from Azure DevOps or GitHub Actions
- Private container registry
- Standard app templates
- Environment-specific folders
- Docker image update process with approval
- Nginx Proxy Manager backup process
The backup scope should include at least:
/appdrive/docker/apps
/appdrive/docker/proxy/nginx-proxy-manager/data
/appdrive/docker/proxy/nginx-proxy-manager/letsencrypt
Application-specific data folders
Those are the locations that allow the platform and hosted apps to be restored with the least confusion.
Final operating principle
The operating principle is:
Keep the VM secure.
Keep apps containerised.
Keep routing centralised.
Keep app files organised.
Keep access controlled.
This platform works best when it stays boring.
One app folder. One Compose file. One container or stack. One proxy host. One HTTPS endpoint.
That is enough structure to support multiple apps without turning a simple hosting platform into an orchestration project.