Hosting Multiple Apps On One Server
This tutorial adds a second Meridian app to a server that already runs one app. The goal is one small VPS, one shared kamal-proxy, and separate state for each service.
Start state:
my-appalready serveshttps://app.example.comonprod-01.example.com.meridian setup,meridian check, andmeridian deployhave already completed formy-app.- The server has one shared
kamal-proxy.containerandmeridian-proxy.network.
End state:
my-blogserveshttps://blog.example.comon the same host.my-blogowns its ownmy-blog.network.- Optional accessories are named and networked so they cannot collide with
my-app.
Topology
Both apps share the public proxy network, but each app keeps its private service network and runtime state.
text
public HTTP(S)
|
v
kamal-proxy.container
|
meridian-proxy.network
/ \
my-app-green my-blog-blue
| |
my-app.network my-blog.network
| |
my-app-postgres my-blog-postgresThe important rule: meridian-proxy.network is shared host infrastructure. <service>.network is private to one app and its accessories.
Existing App
my-app has its own project directory and its own .meridian/deploy.yml. Service-prefixed secret names and accessory names make the host state obvious.
yaml
service: my-app
image: ghcr.io/example/my-app:latest
servers:
web:
hosts:
- prod-01.example.com
proxy:
host: app.example.com
ssl: true
app_port: 8000
healthcheck:
path: /health
env:
clear:
MARTEN_ENV: production
MY_APP_DATABASE_HOST: my-app-postgres
MY_APP_DATABASE_NAME: my_app
MY_APP_DATABASE_USER: my_app
secret:
- MY_APP_DATABASE_PASSWORD
- MY_APP_SECRET_KEY_BASE
ssh:
user: deploy
keys:
- ~/.ssh/id_ed25519
accessories:
my-app-postgres:
image: docker.io/library/postgres:18-alpine
host: prod-01.example.com
network: my-app.network
volumes:
- my-app-pgdata:/var/lib/postgresql
env:
clear:
POSTGRES_DB: my_app
POSTGRES_USER: my_app
POSTGRES_PASSWORD_FILE: /run/secrets/MY_APP_DATABASE_PASSWORD
secrets:
- MY_APP_DATABASE_PASSWORD
# readiness inferred from the postgres image (pg_isready)Notice the names:
service: my-appowns~/.local/state/meridian/services/my-app/.- The accessory is
my-app-postgres, notpostgres. - Secrets are
MY_APP_*, not generic names such asDATABASE_PASSWORD. - The accessory joins
my-app.network, notmeridian-proxy.network.
Add The Second App
Create a separate project directory for my-blog. Do not reuse the first app's .meridian/deploy.yml; each service owns one config and one runtime-state directory.
Its config has the same shape. Only the names, the proxy route, and the app port differ — every MY_APP_* becomes MY_BLOG_*, and nothing is shared:
yaml
service: my-blog
image: ghcr.io/example/my-blog:latest
servers:
web:
hosts:
- prod-01.example.com # same box
proxy:
host: blog.example.com # different hostname
ssl: true
app_port: 3000 # different port inside the container
healthcheck:
path: /up
env:
clear:
NODE_ENV: production
MY_BLOG_DATABASE_HOST: my-blog-postgres
MY_BLOG_DATABASE_NAME: my_blog
MY_BLOG_DATABASE_USER: my_blog
secret:
- MY_BLOG_DATABASE_PASSWORD
- MY_BLOG_SESSION_SECRET
accessories:
my-blog-postgres: # not 'postgres', not 'my-app-postgres'
image: docker.io/library/postgres:18-alpine
host: prod-01.example.com
network: my-blog.network # its own private network
volumes:
- my-blog-pgdata:/var/lib/postgresql
env:
clear:
POSTGRES_DB: my_blog
POSTGRES_USER: my_blog
POSTGRES_PASSWORD_FILE: /run/secrets/MY_BLOG_DATABASE_PASSWORD
secrets:
- MY_BLOG_DATABASE_PASSWORDThe ssh: block is identical to the first app's and is omitted here.
Neither database publishes a host port: each app reaches its own Postgres by container name on its private <service>.network, so there's nothing to collide on. Only add port: if you genuinely need to reach a database from the host (e.g. an external admin tool) — and then give each service a distinct host port (5432:5432 for one, 15432:5432 for another) to avoid clashes.
Point DNS First
Before deploying the second app, point the new hostnames at the same server.
bash
dig +short app.example.com
dig +short blog.example.comBoth should resolve to prod-01.example.com or its public IP. If you also configure assets.host, point that hostname before deploying with ssl: true.
Prepare Secrets And Setup
Run these commands from the my-blog project directory.
bash
meridian secret gen MY_BLOG_SESSION_SECRET
meridian secret gen MY_BLOG_DATABASE_PASSWORD
meridian setupsecret gen and secret set default to the web role, which is enough here because the example accessory and web app are on the same host. For a different role, pass --role ROLE.
This does not create a second proxy. It uploads or refreshes the shared meridian-proxy.network, the service's private my-blog.network, and kamal-proxy.container, starts the service network, ensures the proxy is running, and lets this service register routes during deploy.
Start Accessories
Start the database after setup has uploaded and started my-blog.network.
bash
meridian accessory start my-blog-postgresCheck For Collisions
Run the preflight check before deploying.
bash
meridian checkThe manifest-collisions probe compares the new my-blog config against service manifests already present on the host:
text
~/.local/state/meridian/services/
my-app/
manifest.json
audit.log
my-blog/
manifest.json
audit.logEach manifest.json records what a service owns: proxy host/path, asset host, published ports, accessory names, generated files, and service-state paths. meridian check fails before deploy if my-blog tries to claim something already owned by my-app.
Useful inspection commands:
bash
ssh deploy@prod-01.example.com 'find ~/.local/state/meridian/services -maxdepth 2 -name manifest.json -print'
ssh deploy@prod-01.example.com 'cat ~/.local/state/meridian/services/my-app/manifest.json'If meridian check reports manifest-collisions: fail, fix the second app's config first. Change the proxy host/path, accessory name, asset host, published host port, or service name until the collision disappears. See the troubleshooting entry for the diagnostic flow.
Deploy The Second App
Once DNS, secrets, accessories, and checks are ready:
bash
meridian deploy
meridian status --host prod-01.example.commy-app and my-blog now have independent release state:
text
~/.local/state/meridian/services/my-app/
active-color
release-state.json
manifest.json
audit.log
~/.local/state/meridian/services/my-blog/
active-color
release-state.json
manifest.json
audit.logA deploy for my-blog should not mutate my-app state. It only registers or updates my-blog routes on the shared proxy.
Read The Audit Logs
Each service has its own audit.log, so incident review starts by checking the service that changed.
bash
meridian audit --host prod-01.example.com --lines 50
ssh deploy@prod-01.example.com 'tail -n 20 ~/.local/state/meridian/services/my-blog/audit.log'
ssh deploy@prod-01.example.com 'tail -n 20 ~/.local/state/meridian/services/my-app/audit.log'Look for recent deploy, rollback, proxy, accessory, and lock entries. If one app appears affected after another app's deploy, compare both audit logs and then inspect both manifests.
Removing One App
Run this from the app directory you want to remove from the proxy.
bash
meridian proxy removeFor my-blog, this removes only my-blog routes and its manifest. If my-app is still registered on the host, Meridian leaves the shared kamal-proxy running.
Use --force only when you intentionally want to remove the shared proxy even though other service manifests still exist:
bash
meridian proxy remove --forceThat is a destructive host-level action. It can interrupt other Meridian apps on the same server.
What Collides
meridian check rejects the first four outright. The last one it cannot see, and it is the one that bites during an incident.
- [ ]
service:is unique. It names the state directory, the network, and every unit. - [ ] Proxy host — or host plus
path— is unique. Two services cannot register the same route. - [ ] Accessory names are service-prefixed. The name becomes the Quadlet and container name, so
postgrestwice on one host is a collision. - [ ] Published accessory host ports do not collide when you set
accessories.<name>.portat all. Most setups should not. - [ ] Secret names are service-prefixed. A shared
DATABASE_PASSWORDparses fine, deploys fine, and then one app's rotation silently breaks the other.
Plus the one thing Meridian cannot check for you: DNS for the new hostname has to point at the server before you deploy with ssl: true.
