OdyNet Apps: Building an Internal Application Portal
OdyNet Apps is an internal application directory for a self-hosted environment. Instead of maintaining a second, manually updated list of application URLs, the portal discovers the web applications already configured on a private NGINX reverse proxy and turns them into a browsable launch page.
The implementation described here uses an internal DNS server, a private NGINX server, a read-only NGINX inventory exporter, and two Docker services: the web portal and a background worker. It is a description of the deployed architecture and the steps needed to reproduce it with the OdyNet Apps application source; it is not a generic NGINX dashboard that works without that application.
Example values: Domains, IP addresses, and tokens in this public guide are illustrative and are not values from the actual OdyNet network. Use your own internal names and addresses. Do not copy private configuration files, credentials, or raw
nginx -Toutput into a public repository.
What the portal does
- Discovers applications from active
server_nameand upstream/proxy settings on the private NGINX instance. - Imports discovered hosts into a local application inventory while retaining portal-specific metadata, such as manually chosen icons and display information.
- Presents discovered applications as launchable cards on an internal web page.
- Supports manual inventory synchronization and a refresh triggered by NGINX configuration changes.
- Performs background availability/health checks and records audit results.
- Identifies entries that disappear from the source inventory so they can be reviewed as orphaned entries rather than silently losing custom metadata.
The portal does not replace NGINX or internal DNS. NGINX continues to route requests; DNS continues to direct clients to the private reverse proxy. The portal is the discovery and presentation layer.
1. How the components fit together
Internal client or connected VPN client
|
| 1. Resolve an internal application hostname
v
Internal DNS server
|
| 2. Return the private NGINX address
v
Private NGINX reverse proxy <------------------------------+
| |
| 3. Forward application requests | read active NGINX
v | configuration
Docker-hosted applications |
|
Private NGINX inventory exporter --------------------------+
|
| 4. Write a filtered, read-only JSON inventory
v
Restricted inventory endpoint
|
| 5. Portal imports inventory on sync
v
OdyNet Apps portal + persistent database
|
+----> Web UI: application cards, icons, status
|
+----> Worker: periodic health checks and audit
Important: The portal uses the private reverse proxy as the source of truth. A separate public NGINX proxy might have different routes or expose a different set of applications; it must not be substituted as the inventory source.
Example network roles
| Component | Illustrative address or name | Purpose |
|---|---|---|
| Internal DNS | 10.20.30.10 |
Resolves internal application names. |
| Private NGINX | 10.20.30.20 |
Terminates and routes internal application requests. |
| Docker host | 10.20.30.50 |
Runs the portal and the applications it indexes. |
| Portal hostname | apps.example.com |
Friendly URL for the application directory. |
| Portal host port | 17021 |
Example of the host port mapped to the portal’s container port 8000. |
Keep the portal and its inventory endpoint accessible only from the intended LAN/VPN networks. If you use the same hostname inside and outside your network, internal DNS can resolve that name to the private proxy while public DNS follows an entirely separate route. Do not assume that a public DNS record provides internal application access.
2. Set up internal DNS first
Create internal DNS records for the portal and each application you want clients to reach. For example:
apps.example.com A 10.20.30.20
notes.example.com A 10.20.30.20
readers.example.com A 10.20.30.20
These records point to the private NGINX server, not directly to each Docker container. NGINX uses the request hostname to select the appropriate server block and forward the request to the correct upstream.
If you use a private DNS zone or split-horizon DNS, verify that LAN clients receive the private address. VPN clients must also be configured to query DNS servers that know these internal records and must have a route to the private proxy.
From a client on the intended network, verify the resolution:
nslookup apps.example.com 10.20.30.10
nslookup notes.example.com 10.20.30.10
Both example hostnames should resolve to 10.20.30.20. DNS only supplies an address; the matching NGINX virtual host is still required.
3. Configure the private reverse proxy
Create a private NGINX virtual host for each application. In a simplified HTTP-only test environment, an application block could look like this:
server {
listen 80;
server_name notes.example.com;
location / {
proxy_pass http://10.20.30.50:17015;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
This is illustrative, not a copy of OdyNet’s production NGINX configuration. Use HTTPS, your existing certificate and shared proxy-header includes, and your normal site configuration conventions in the real deployment. The example backend and port are placeholders; replace them with the actual application listener.
Test and reload NGINX using your normal change process:
sudo nginx -t
sudo systemctl reload nginx
Check that a client using internal DNS can open the application hostname before attempting portal discovery. A card in the portal will not repair a broken DNS record, upstream, or virtual host.
4. Export an inventory from the private NGINX server
The OdyNet deployment uses a read-only exporter named odynet-nginx-inventory.py. The exporter runs nginx -T to inspect NGINX’s effective configuration, extracts the information needed by the portal, and atomically writes a JSON inventory at:
/var/lib/odynet-apps/nginx-inventory.json
Reading nginx -T matters because active configuration may be assembled from multiple included files; reading one hand-picked site file can miss active virtual hosts.
Do not expose the raw nginx -T output. It can contain sensitive configuration. Only serve the filtered inventory emitted by the exporter, and review the inventory itself for private details before publishing any example of it.
The deployed exporter is managed by a systemd service named odynet-nginx-inventory.service. A previous timer-based export was replaced by an event-driven arrangement: NGINX start/restart/reload triggers an inventory refresh, and a post-export sync trigger informs the portal that a new inventory is available.
The event hook is installed as an NGINX systemd drop-in; its deployment-specific unit file, exporter source, and sync-trigger script need to be taken from the actual application project. Do not substitute a made-up ExecReload or callback command: systemd drop-in behavior depends on the existing NGINX unit and the authentication used by your portal.
Verify the exported data
On the private NGINX host, after installing the actual exporter and its systemd unit:
sudo systemctl status odynet-nginx-inventory.service --no-pager
sudo python3 -m json.tool /var/lib/odynet-apps/nginx-inventory.json >/dev/null
The second command checks JSON syntax; it does not validate the portal’s expected schema. Inspect the inventory locally and make sure it contains only the intended internal web applications.
5. Make the inventory reachable only by the portal host
The deployed design exposes the generated JSON through a restricted private-NGINX location, rather than giving the Docker host unrestricted filesystem access to the proxy. A representative location is:
location = /_odynet/nginx-inventory.json {
allow 10.20.30.50; # Portal/Docker host only
deny all;
default_type application/json;
alias /var/lib/odynet-apps/nginx-inventory.json;
}
Place this inside an internal-only NGINX server block, and adapt its TLS settings and filesystem permissions to your environment. This example shows the access-control pattern; the precise URL and implementation must match the portal’s configured inventory URL and exporter file location. An allow rule is an additional control, not a replacement for preventing public routing to this endpoint.
Verify that the Docker host can fetch the filtered inventory and that an unrelated client cannot. Use your actual private URL in place of the placeholder:
curl --fail --show-error https://internal-proxy.example.com/_odynet/nginx-inventory.json
Avoid using curl -k as a permanent workaround for certificate validation problems.
6. Deploy the web portal and background worker
The existing OdyNet Apps deployment uses these application roles and paths:
/opt/stacks/odynet-apps/
docker-compose.yml # Docker Compose stack
/srv/docker/odynet-apps/
app/ # Application source
config/
app.env # Runtime settings and secrets: PRIVATE
seed_apps.json # Optional initial application metadata
data/
odynet-apps.db # Persistent SQLite database
icons/uploads/ # Uploaded icon files
backups/ # Application backup files
The Compose stack runs odynet-apps (the Flask web application) and odynet-apps-worker (background discovery/health work). The web application listens on container port 8000; the known host-side port mapping is 17021:8000. Both services must receive the configuration and persistent mounts that match the actual application source and Compose file.
Source required to reproduce this step: This custom portal is not installed by
docker compose upalone. Obtain the application’s source code, Dockerfile/dependencies, and its realdocker-compose.ymlbefore starting it. The exact Compose content and startup command are intentionally not fabricated here.
After placing those files in their expected locations, use the standard deployment workflow:
cd /opt/stacks/odynet-apps
sudo docker compose config --quiet
sudo docker compose up -d --build
sudo docker compose ps
docker compose config --quiet checks Compose syntax and variable interpolation; it does not guarantee that application settings, inventory access, or credentials are correct. Keep app.env, database files, backup archives, and uploaded assets out of public repositories. Back up the persistent data before upgrades.
Runtime configuration
The deployed application has settings for the following functions. Set them in the format required by the actual app.env and Compose configuration:
| Setting | Role |
|---|---|
NGINX_INVENTORY_URL |
URL of the filtered inventory on the private NGINX server. |
| Inventory source/timeout/TLS settings | Limit where inventory may be fetched from, and how connections are validated. |
| Sync token and allowed-source settings | Authenticate and restrict automatic refresh callbacks. |
| Discovery settings | Determine how new entries appear in the portal. |
HEALTH_CHECK_INTERVAL |
Background health-check cadence; 300 seconds was used in the deployed configuration. |
| Timezone and icon-size settings | Control application presentation and uploads. |
Use a dedicated random sync token, ensure that source checking agrees with the actual network path, and keep TLS verification enabled for HTTPS requests. Do not put a literal real token into published examples.
7. Reverse-proxy the portal itself
Give the portal its own internal DNS record pointing to private NGINX:
apps.example.com A 10.20.30.20
Add an internal-only NGINX virtual host that forwards the portal hostname to the Docker host’s portal port, conceptually:
server {
listen 80;
server_name apps.example.com;
location / {
proxy_pass http://10.20.30.50:17021;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
As above, the port-80 block is only a simplified demonstration. In production, use your existing internal HTTPS configuration and shared NGINX proxy-header includes rather than duplicating directives.
Once DNS, proxy routing, and the portal container are working, open https://apps.example.com/ from a client that is on the internal network or permitted VPN.
8. Synchronization, updates, and health checks
There are three different refresh mechanisms, and they should not be conflated:
- NGINX inventory refresh: The private NGINX exporter rebuilds the filtered JSON after relevant NGINX lifecycle/configuration events. The deployment uses systemd hooks and a sync-trigger script for this path, not a recurring five-minute export timer.
- Portal discovery/import: The portal imports the inventory, adds newly discovered applications, retains custom metadata on existing entries, and can flag entries missing from the newest inventory as orphaned for review. The original implementation also supports manual sync and a health/audit view.
- Health and presentation: The worker checks application health on its configured interval (five minutes in the referenced deployment). The browser-facing page can refresh its displayed information approximately every ten seconds. A ten-second UI update does not mean NGINX is re-parsed or every application is health-checked every ten seconds.
Uploaded icons and application metadata are maintained in the portal’s persistent data, not in NGINX server blocks. Review orphaned entries before deleting them so an accidental configuration omission does not erase useful annotations.
9. Validate the complete path
Work through the following checks in order; each verifies a different dependency:
- DNS: A client on the intended LAN/VPN resolves
apps.example.comand a sample application hostname to the private NGINX address. - Routing: The sample application’s hostname opens correctly through private NGINX even without using the portal.
- Export: The inventory service on private NGINX succeeds and produces valid JSON at its local output path.
- Access control: The portal host can fetch the inventory endpoint; an unauthorized host cannot.
- Import: A manual sync in OdyNet Apps discovers the expected application and displays a working launch link.
- Change propagation: Add or edit a test private NGINX virtual host, run
nginx -t, reload NGINX, and confirm the inventory and portal reflect the change. - Health: Stop a test upstream briefly and confirm that the worker’s next health check reports the change; restore the upstream afterward.
- Persistence: Restart the portal containers and verify that the database, manually edited metadata, and uploaded icons remain intact.
Do not test by exposing the inventory endpoint on the public proxy or by sharing raw production configuration dumps.
10. Troubleshooting
| Symptom | Check first |
|---|---|
| An application opens by IP but not hostname | Internal DNS record, client DNS selection, and private NGINX server_name. |
| The application opens normally but does not appear in the portal | Confirm it is configured on the private NGINX, present in the filtered JSON, and included by the import rules. |
| The inventory file is missing or stale | Check the exporter systemd unit, its nginx -T permissions, and the NGINX lifecycle hook. |
| The portal cannot fetch the JSON | Check endpoint ACL, Docker-host source address, DNS, TLS certificate validation, and NGINX_INVENTORY_URL. |
| NGINX changes do not appear immediately | Check the post-export sync trigger, callback token/source restrictions, and the portal’s last successful import. |
| Cards appear but health status is old | Check odynet-apps-worker, its logs, and HEALTH_CHECK_INTERVAL; the ten-second UI refresh is not a health-check schedule. |
| Custom icons or metadata disappear after restart | Verify persistent volumes and SQLite/icon storage paths. |
Scope of this public guide
This guide documents the deployed design, component responsibilities, network and DNS prerequisites, operational order, and validation process. The project-specific exporter source, systemd drop-in, sync-trigger implementation, complete Compose file, application code, and full app.env variable names have not been reproduced here because the live, sanitized files were not available for verification. Those are the remaining pieces needed to turn this architecture guide into an end-to-end copy-and-paste installation tutorial.