Sirio is in private beta: sources and installer are sent to testers by email. Ask to join.
Domains and sites
Sirio administers a single machine, but that machine can host several projects with different
domains at once. This page explains the model and how to publish your first site.
site ──┬── domains ── routes ──→ container or host:port
├── containers (assigned to the site, or shared)
├── pipelines
└── repository
Two distinct axes, and the distinction is what matters:
ownership: a container belongs to one site, or to none. It’s used to group things, filter
the list and decide what disappears together;
exposure: a route ties domain + path to a destination. It’s many-to-many.
It follows that there is no “cross-domain container” switch — there’s no need for one. If the
same container must answer on both api.shop.example and api.blog.example, you create two
routes, even from different sites. A container with no site (shared) is simply a service that
doesn’t belong to a particular project — a Postgres, a Redis — and stays out of every site’s
individual dashboard.
A pipeline linked to a site assigns its containers to that site on its own: after every
successful compose step with up, Sirio asks Compose which containers the project has and binds
the shared ones to the pipeline’s site. A container already assigned to another site stays where
it is — an explicit choice made in Containers is never overwritten by a deploy. Linking the site
to an existing pipeline takes effect at its next run, even if that run doesn’t recreate anything.
A site is not a security boundary: Sirio has a single administrator account, and whoever logs
in sees and administers everything. Multisite is about organization, not multi-tenancy.
With SIRIO_PROXY_ENABLED=true Sirio manages a Caddy that runs as a container, not as a host
service. The reason is concrete: Sirio’s systemd unit has ProtectSystem=strict and only grants
write access to SIRIO_DATA_DIR, so the sirio user couldn’t write to /etc/caddy or reload a
host service. Going through the Docker socket, which Sirio already uses, needs no extra
privilege.
The cycle, on every change to sites, domains or routes:
Sirio regenerates the Caddyfile at SIRIO_DATA_DIR/proxy/Caddyfile.new;
validates it with caddy validate inside the container;
if it’s valid, activates it and reloads Caddy; if it’s not, the previous configuration stays
in effect and the error is returned to the UI. A misconfigured domain never takes down sites
already online;
if the reload fails, rolls back to the previous version.
The containers targeted by a route are attached to the sirio-edge network, where Caddy resolves
them by name. From that point on there’s no need to publish ports on the host anymore: the
port given in the route is the container’s internal port.
A reconciliation also runs after every successful pipeline and every 60 seconds. This matters
because a deploy recreates containers, which without this pass would lose their attachment to the
network.
Find the line Environment=SIRIO_PROXY_ENABLED=false (the unit installed by install.sh already
has it, disabled) and change false to true:
Environment=SIRIO_PROXY_ENABLED=true
Save, then apply the change:
Terminal window
sudosystemctldaemon-reload
sudosystemctlrestartsirio-sysadmin
Check it started without errors with systemctl status sirio-sysadmin.
2. Free up ports 80 and 443. If you already have a Caddy or an nginx on the host, stop it: the
proxy container won’t be able to listen on them. See the migration section further down.
If a container publishes them instead — typically an app that terminates its own TLS, such as
FrankenPHP or a Caddy/nginx container — take the ports away from it rather than stopping it: a
stopped container would fail to start later, or race the proxy for them after a reboot. For a
Compose service, in a compose.override.yaml next to the compose file:
services:
web:
ports: !reset []
then docker compose up -d, and have the app serve plain HTTP: the proxy terminates TLS and
passes X-Forwarded-For/X-Forwarded-Proto, which the app must trust (Symfony’s
TRUSTED_PROXIES, Caddy’s trusted_proxies, and so on) to see the visitor’s address and HTTPS.
Install names any container still publishing the proxy’s ports.
3. Install the proxy from the Proxy page of the panel. Sirio creates the sirio-edge
network, a volume for certificates and the sirio-caddy container.
The certificate volume isn’t a minor detail: without it, every time the container is recreated
it would redo the ACME requests, and Let’s Encrypt’s issuance limits run out fast.
4. Set the Let’s Encrypt email address on the same page. It’s optional but recommended: it
receives expiry warnings.
5. Create the site, the domain and the route. In Sites → new site, then new domain, then a
route. The domain’s DNS must already point to this machine: the Verify DNS button shows what it
resolves to, to compare against the VM’s public IP.
The certificate arrives on its own on the first HTTPS access.
The proxy managed by Sirio can also act as HTTPS in front of Sirio itself, not just the sites it
hosts. The typical case: you don’t have (or don’t want to keep) a separate Caddy or nginx on the
host just for the panel. There are two ways; the first is the recommended one.
Set a domain that points to this machine and sirio-caddy serves the panel at
https://DOMAIN:22722, with a Let’s Encrypt certificate it obtains and renews on its own:
80 and 443 stay with the sites. The panel gets its own port (SIRIO_PANEL_PORT, default
22722), which sirio-caddy publishes next to them. Caddy still uses 80/443 for the certificate
challenge, which it already owns, so no extra configuration is needed. If you’d rather have the
panel on 443 too, under its own name, set SIRIO_PANEL_PORT=443: Caddy tells the panel and the
sites apart by domain.
No plaintext port.sirio-caddy talks to Sirio through a Unix socket,
SIRIO_DATA_DIR/proxy/sirio.sock, not over TCP. SIRIO_LISTEN can then stay on 127.0.0.1,
and only the proxy can connect, because only the proxy mounts that directory.
Real client IPs. On the socket, and only there, Sirio trusts the X-Forwarded-For header
that Caddy sets. Login rate limiting and the activity log therefore see the visitor’s address,
not the proxy’s.
Steps, with the domain your provider assigned (for example vps-005451d4.vps.ovh.net) or one of
yours:
Install the proxy (Proxy → Install) if you haven’t yet. Ports 80 and 443 must be reachable
from the internet for the certificate, including any firewall in the provider’s control panel.
Edit the service with sudo systemctl edit sirio-sysadmin (the override survives updates):
SIRIO_LISTENhas to move off the panel port. Docker publishes sirio-caddy’s port on
every address, and Linux refuses that bind if Sirio holds the same port on any of them, loopback
included. Sirio refuses to start with an explicit error rather than let the proxy fail.
SIRIO_SECURE_COOKIES is forced to true.
sudo systemctl restart sirio-sysadmin. On startup Sirio recreates sirio-caddy once to
publish the new port. The certificates live on the sirio-caddy-data volume and survive, and
the sites are down for a few seconds. If the port is already taken on the host, the old
container stays as it was, Proxy shows a warning and Sirio tries again every 15 minutes.
Open https://vps-005451d4.vps.ovh.net:22722/: the certificate arrives on the first access.
Proxy shows the address as a badge.
Things to know:
The firewall doesn’t see this port. Docker publishes ports with its own iptables rules,
ahead of ufw and firewalld: there is no need for ufw allow 22722, and a ufw deny won’t block
it. To limit the panel to your IP, filter it in Docker’s DOCKER-USER chain, for example
sudo iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 22722 --ctdir ORIGINAL ! -s YOUR.IP -j DROP
(not persistent across reboots: save it with your distro’s mechanism).
Fallback access. If the proxy stops, the panel is still on SIRIO_LISTEN, on loopback only.
Reach it through an SSH tunnel: ssh -L 22723:127.0.0.1:22723 user@server, then
http://localhost:22723. Chrome and Firefox accept the Secure cookie on localhost; Safari
may not.
Disabling it. Remove SIRIO_PANEL_DOMAIN and restart: Sirio recreates sirio-caddy without
the extra port.
The alternative is an ordinary route pointing at Sirio’s TCP port. It needs nothing beyond the
panel, but Sirio has to keep listening on an address the container can reach. It also sees every
request as coming from the proxy: the login rate limit is then shared by every visitor.
The tricky part is the destination address. Sirio listens on the host, not in a container, and
sirio-caddy runs on an isolated Docker network (sirio-edge): its 127.0.0.1 is the container
itself, not the machine. Don’t write 127.0.0.1 in the route — it never resolves to the panel,
and the symptom is a domain with no certificate, because the request never reaches a Caddyfile
that actually contains it (see Troubleshooting further down for how to spot
this from the outside).
The right address is host.docker.internal: Sirio configures sirio-caddy with that alias
already pointed at the host (--add-host host.docker.internal:host-gateway), so it works
regardless of the subnet Docker assigns to the network — which varies from machine to machine and
can change if the network is recreated.
If the sirio-caddy container already existed before this alias (Sirio versions predating this
change), it needs to be recreated once: sudo docker rm -f sirio-caddy, then Proxy → Install
from the panel. Certificates and configuration live on a volume/disk and aren’t lost.
Concrete steps, with an example domain panel.example.com that already points to this machine:
Sites → new site. No repository needed: just a name, e.g. sirio — it’s just a container
for the domain.
In the site, a new domain: panel.example.com, TLS auto.
In the domain, a new route:
Field
Value
Path
/
Type
host
Destination
host.docker.internal
Port
22722 (or the value of SIRIO_LISTEN, if you changed it)
Open https://panel.example.com/: the certificate arrives on the first access.
Don’t move SIRIO_LISTEN to 127.0.0.1 with this setup.host.docker.internal is the
Docker bridge’s address on the host, not loopback, so a Sirio listening on 127.0.0.1 refuses the
proxy’s connections and the domain stops working. To close the plaintext port, switch to
SIRIO_PANEL_DOMAIN instead: SIRIO_PANEL_PORT=443 keeps
the same https://panel.example.com/ address. Delete the route first, because the same name on
443 can’t be both a site and the panel.
Sirio always generates reverse_proxy destination:portin plain HTTP: there is, today, no way
to declare that the upstream wants TLS. If the destination container is configured to handle
HTTPS on its own — common with images that bundle a Caddy-based web server, such as FrankenPHP,
when they have a variable holding the public domain name (e.g. SERVER_NAME) — this happens:
on the HTTP port the container redirects to https:// (responds 308), and that redirect
goes back to the browser pointing again at the public domain: the loop closes on Sirio’s Caddy,
which redoes the same request, in a loop;
on the HTTPS port the container expects a real TLS handshake, which Sirio doesn’t perform: the
connection fails with a protocol error (from Sirio’s container it shows up as a 400 Bad Request or a TLS alert, not as slowness or a timeout).
In both cases the site stays unreachable even if DNS, the public domain’s certificate and the
route are all correct — because the problem is in the second hop, between Caddy and the
container, not in the first.
The fix belongs in the destination container, not in Sirio: turn off its automatic HTTPS and
make it speak plain HTTP, then point the route at that port. For a FrankenPHP image
(dunglas/frankenphp or derivatives) this typically means changing, in its docker-compose.yml:
environment:
SERVER_NAME: docker.dev.example.com
SERVER_NAME: ":80"
and in Sirio’s route, the port from 443 to 80. The principle applies to any image that does
automatic HTTPS internally (Traefik-ready, Caddy-based, etc.): the variable is named differently
case by case, but the idea — “HTTP-only server on a known port” — is the same.
From the command line on the machine, in the order it’s worth checking them:
1. Is the domain actually in the generated Caddyfile?
Terminal window
sudodockerexecsirio-caddycat/etc/caddy/Caddyfile
If the domain doesn’t show up, it was never saved (or the route is disabled) — it’s not a DNS or
certificate problem: those aren’t even attempted. This is also visible from the panel, in Proxy
→ Generated configuration.
A connection refused toward the host is usually 127.0.0.1 written by mistake instead of
host.docker.internal, or SIRIO_LISTEN moved to 127.0.0.1 while a host route still points at
it (see above).
With SIRIO_PANEL_DOMAIN the proxy reaches the panel through the socket instead, which you can
query from the host:
No answer means Sirio isn’t running or doesn’t have SIRIO_PANEL_DOMAIN set. The startup log
(journalctl -u sirio-sysadmin) has a panel served by the proxy line with the address and the
socket. If the socket answers but the domain is refused, look for the proxy isn't installed in
the same log: Sirio never installs sirio-caddy on its own, so reach the panel on SIRIO_LISTEN
(an SSH tunnel if it’s on loopback) and install it from Proxy. A 400/TLS error with a container destination is almost
always the case from the previous section: the backend expects HTTPS and Caddy is talking to it
in plaintext.
4. Is the public certificate the right one? From the outside (or from the machine itself,
forcing resolution):
If the certificate is from Let’s Encrypt and the subject is the right one, the problem is no
longer TLS: it’s the next hop, toward the backend (point 3).
Adding an external container to the project: phpMyAdmin example
Not everything has to go through a project’s docker-compose.yml. A typical case: you want a web
interface for a site’s database without touching its compose file, maybe because it’s shared
across several projects or because you don’t want it to disappear on every docker compose down.
From Containers → New container you can create the container and attach it to the database’s
network, without editing any file.
The key point is the network: a container reaches another by name only if they share a Docker
network. If the database runs in a compose project called, say, shop, its default network is
called shop_default (check it with docker network ls on the machine, or look at the database
container in Containers: the column shows the site, but the actual network is visible with
docker inspect DB_NAME --format '{{json .NetworkSettings.Networks}}').
1. Containers → New container, with:
Field
Value
Name
phpmyadmin
Image
phpmyadmin:latest
Ports
8081:80 (any port on the host)
Environment variables
PMA_HOST=shop_db (the database container’s name) PMA_PORT=3306
Networks
shop_default
Auto-restart
unless-stopped
No need to assign it to a site: it stays shared, which makes sense if you reuse it for several
databases on the same host — just add each one’s network to the list.
2. Open http://MACHINE-IP:8081. phpMyAdmin resolves shop_db by name because it’s now on
the same network.
If you’d rather expose it with a domain instead of a port, the same pattern described in
Exposing the panel itself applies: create a site, a domain and a
route of type container pointing at phpmyadmin, port 80. The route also attaches it to the
sirio-edge network — no need to add it by hand among the container’s networks at creation time —
but you still need to manually add the database’s network (shop_default), because that one has
nothing to do with the proxy.
Watch the order: if you change the networks of an already-created container, today that has to
be done from the command line (docker network connect shop_default phpmyadmin) — the
Networks field in the form only applies at creation time, Sirio doesn’t yet offer a way to
edit them on an existing container.
A container can be on several networks at once: you don’t need one phpMyAdmin per project, just
attach the same container to every database’s network it needs to reach, one per line in the
Networks field. With two projects shop and blog, each with its own database (shop_db on
network shop_default, blog_db on network blog_default):
Field
Value
Networks
shop_default blog_default
Environment variables
PMA_HOSTS=shop_db,blog_db
PMA_HOSTS replaces PMA_HOST (singular): it’s the variable the official phpMyAdmin image
reads to populate multiple entries — a comma-separated list of hosts, no spaces. If a database
listens on a port other than 3306, also add PMA_PORTS with the same number of values in the
same order as PMA_HOSTS (e.g. PMA_PORTS=3306,3307); if they’re all on 3306 you don’t need
it.
At login, phpMyAdmin shows a dropdown with shop_db and blog_db: username and password are
entered there, not in the environment variables. That’s intentional — if the two databases have
different credentials (the normal case, one user per project), setting PMA_USER/PMA_PASSWORD
at the container level would only work for one entry and break the other. If they genuinely share
the same credentials, those two variables still work and phpMyAdmin pre-fills the login.
Every container added later follows the same pattern: add its network to the list (at creation
time, or with docker network connect on an existing phpMyAdmin) and its name to PMA_HOSTS.
Environment variables are only read at startup, so change PMA_HOSTS from Environment
overrides on the phpMyAdmin container: it recreates the container with the new value, keeping
the rest of its configuration (see Environment overrides).
A shared service reached by the apps: mailpit example
Same principle as the phpMyAdmin case — a container outside a project’s docker-compose.yml,
manually attached to its network — but in the opposite direction: here it’s not the added
container that needs to reach a project’s service, it’s the project’s containers that need to
reach it. A typical case is a shared mailpit for capturing mail from several projects in
development/staging without installing one per compose file.
1. Containers → New container, with:
Field
Value
Name
mailpit
Image
axllent/mailpit:latest
Ports
8025:8025 (web UI, any port on the host)
Networks
shop_default (the network of the project that needs to send mail)
Auto-restart
unless-stopped
2. In the app, point the SMTP host at the container’s name: e.g.
MAILER_DSN=smtp://mailpit:1025 (the name and the variable change from framework to framework,
port 1025 is mailpit’s SMTP port, not the 8025 of the UI).
The container’s name matters, not just the network. A container reaches another by name
only if the name matches what the app expects — sharing the network isn’t enough. If you create
it with a name different from the one the app looks for (e.g. a prefix your orchestrator applies
automatically, project_mailpit instead of mailpit), DNS resolution fails even though the
networks match; check it from inside the app container with
docker exec APP_NAME getent hosts mailpit (a non-zero exit code means it doesn’t resolve). The
simplest fix is to name the container exactly as the app looks for it, as in the table above; the
alternative is adding an alias on the network (it has to be redone on every reconnection, it
isn’t persistent on its own):
For several projects, just attach the same mailpit to every network involved, one per line in the
Networks field — exactly like the shared phpMyAdmin above — as long as it’s reachable in each
one under the name that project expects.
Several routes can be defined on the same hostname:
Path
Destination
/api
container api, port 3000
/
container web, port 80
The most specific one wins, regardless of insertion order. The path is not stripped before
forwarding the request: the application behind /api receives /api/.... If you need the
opposite, for now the right place to handle it is the application’s own configuration.
A route can ask visitors for a username and password before reaching the application — the same
thing Apache does with AuthType Basic and an .htpasswd file. The credentials live in access
lists, which belong to a site: create one in the site’s Access lists card, add its users, then
pick it in the Access field of each route it should protect. One list can protect several routes,
on different domains of the same site too, so adding a colleague of the client once lets them into
staging and preview alike.
Access list users are the sites’ visitors, not Sirio accounts: they never reach the panel, and
Sirio’s own users don’t get into a protected route unless they’re on its list.
What to know:
The protection is per route. With /admin protected and / public, everything outside
/admin stays public. To protect a whole domain, set the list on its / route (and on every
other route of the domain).
Only the bcrypt hash of each password is stored, so a password can’t be shown again: note it
down (or use Generate) before saving, and set a new one if it’s lost. Passwords follow the same
rules as Sirio’s own (at least 10 characters).
Importing an .htpasswd file keeps the bcrypt entries ($2y$, written by htpasswd -B) and
lists the others, which the proxy can’t read ($apr1$ MD5, {SHA}, crypt): those users need a
new password. Hashes with a bcrypt cost above 12 are refused too, since the proxy checks the hash
on every attempt, wrong ones included.
The application never sees the credentials: the proxy removes the Authorization header
before forwarding. An application with its own login behind a protected route keeps working with
its cookies, but one that expects HTTP auth of its own can’t sit behind a list.
A list with no users lets nobody in (the route answers 403) rather than going public, and a
list can’t be deleted while a route still requires it.
On a domain with TLS off the browser sends the password in clear text: protect it only when
HTTPS is terminated before Sirio’s proxy.
There is no rate limit on these passwords, like on any HTTP Basic Auth: choose long ones.
Managing a site’s lists takes the site’s Domains permission, the same as editing its routes.
In the generated Caddyfile a protected route becomes a basic_auth block in front of
reverse_proxy, which needs Caddy 2.8 or later. The managed proxy’s image is pulled once, when
the proxy is installed: if yours predates 2.8 (sudo docker exec sirio-caddy caddy version), run
sudo docker pull caddy:2-alpine && sudo docker rm -f sirio-caddy, then Proxy → Install. The
certificates live on the sirio-caddy-data volume and survive.
The upstream is always plain HTTP. The route generates reverse_proxy destination:port
with no scheme: Sirio never speaks TLS with the destination container or host, whatever port is
given. A backend that handles HTTPS on its own needs to be turned off on that front — see
Backends that handle their own TLS or redirect.
Wildcards.*.example.com is accepted by validation, but the certificate requires DNS-01
verification, which in Caddy needs a plugin per DNS provider and therefore a purpose-built
image. With the standard image a wildcard domain doesn’t get a certificate: DNS verification
flags it.
The path is not stripped before forwarding (see above).
Redirects, aliases and custom headers don’t exist yet: www.example.com and example.com
currently have to be declared as two domains with the same routes.
The DNS check is a warning, not a block: the machine’s public IP can’t be reliably
determined from the inside (NAT, IPv6, a proxy in front), and rejecting a domain based on an
unreliable comparison would block legitimate configurations.
If you currently have a Caddy or an nginx on the host publishing the panel:
note down the domains and destinations it handles;
in Sirio, create the corresponding sites and domains, with their routes. For the panel itself
set SIRIO_PANEL_DOMAIN to its domain, with SIRIO_PANEL_PORT=443 to keep the same address,
rather than adding a route (full details and steps in
Exposing the panel itself);
check the generated configuration in Proxy → Generated configuration: it’s read-only and
exists precisely so it can be inspected before making the switch;
stop the existing proxy (systemctl stop caddy, systemctl disable caddy);
install the panel-managed proxy.
Step 4 is the only moment of downtime. If something goes wrong you can always restart the old
proxy: its configuration hasn’t been touched.