Production deployment
Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.
This guide takes a fresh VPS and deploys Pia Server behind Caddy with automatic Let’s Encrypt TLS, from images pulled out of GitHub Container Registry.
Prerequisites: A server with Docker and Docker Compose installed, and a domain whose DNS you control. Throughout, <your-domain> is the host serving the API and <server-ip> is its public address.
1. DNS records
Section titled “1. DNS records”Point the domain at your server’s public IP.
| Type | Name | Value |
|---|---|---|
| A | <your-domain> |
<server-ip> |
docker-compose.prod.yml also ships the marketing site and this documentation as their own services. Add an A record for each hostname you intend to serve, or drop the services you do not want from the compose file.
Caddy needs DNS to resolve before it can issue certificates. Verify propagation:
dig <your-domain> +shortIt should return your server IP.
2. Install Docker on the server
Section titled “2. Install Docker on the server”ssh root@<server-ip>curl -fsSL https://get.docker.com | shdocker --versiondocker compose version3. Mount the persistent volume
Section titled “3. Mount the persistent volume”If your provider offers block storage — a Hetzner Cloud Volume, for instance — attach it, then mount it on the server:
# Find the devicels /dev/disk/by-id/
# Format (first use only — destroys data!)mkfs.ext4 /dev/disk/by-id/scsi-0HC_Volume_<id>
# Mountmkdir -p /mnt/hc-volumemount /dev/disk/by-id/scsi-0HC_Volume_<id> /mnt/hc-volume
# Persist across rebootsecho '/dev/disk/by-id/scsi-0HC_Volume_<id> /mnt/hc-volume ext4 defaults 0 2' >> /etc/fstabIf your mount path differs from /mnt/hc-volume, update docker-compose.prod.yml after copying it to the server (step 6).
4. Authenticate Docker with ghcr.io
Section titled “4. Authenticate Docker with ghcr.io”The images are served from GitHub Container Registry, and the packages are private — the server needs credentials before it can pull. Request registry access from kontakt@pia-ai.de alongside your licence, then on the server:
echo "<your-token>" | docker login ghcr.io -u <your-username> --password-stdinYou should see Login Succeeded.
5. Production environment files
Section titled “5. Production environment files”Configuration lives in two files. .env.prod is mounted into postgres and umami; .env.prod.server is mounted into pia-server and the short-lived pia-migrate. The division is deliberate: .env.prod carries POSTGRES_PASSWORD, the owner role’s password, and handing that to the application container would let anything running in the process read the owner credential straight out of its own environment — which is the whole of what the role split exists to prevent. pia-migrate is the one container that receives it, composed at the Compose layer rather than stored in either file, and it exits before pia-server starts.
The infrastructure side:
mkdir -p /opt/piacat > /opt/pia/.env.prod << 'EOF'POSTGRES_PASSWORD=<db-owner-password>TEMPORAL_DB_PASSWORD=<temporal-db-password>VECTORDB_PASSWORD=<vectordb-password>APP_SECRET=<random-64-hex-chars>EOFThe application side:
cat > /opt/pia/.env.prod.server << 'EOF'ASPNETCORE_ENVIRONMENT=ProductionDatabase__Provider=postgresqlDatabase__ConnectionString=Host=postgres;Database=pia;Username=pia;Password=<db-owner-password>Jwt__SecretKey=<random-string-at-least-32-chars>Encryption__MasterKey=<random-64-hex-chars>Knowledge__ConnectionString=Host=pia-vectordb;Database=pia_knowledge;Username=pia;Password=<vectordb-password>EOFchmod 600 /opt/pia/.env.prod /opt/pia/.env.prod.serverGenerate secure values:
openssl rand -base64 32 # Jwt__SecretKeyopenssl rand -hex 32 # Encryption__MasterKey and APP_SECRET (64 hex chars exactly, one each)openssl rand -base64 24 # POSTGRES_PASSWORD, then again for TEMPORAL_DB_PASSWORD and VECTORDB_PASSWORDLeave Knowledge__ConnectionString empty to run without the knowledge base entirely. Everything else the server reads — AI providers, embeddings and chunking, OAuth, admin emails — is optional and documented in .env.prod.server.example, with the infrastructure keys in .env.prod.example. Both example files ship with the server source rather than living on the host: a deploy copies only the four files that hold no secrets — docker-compose.prod.yml, Caddyfile, init-db.sh and temporal-dynamicconfig.yaml.
TEMPORAL_DB_PASSWORD is the one key in .env.prod with no fallback in the compose file. Unset, temporal-db-init creates the Temporal role with an empty password and the Temporal server never connects — while pia-server starts fine regardless, because it deliberately does not depend on Temporal.
Existing hosts: split the file you already have
Section titled “Existing hosts: split the file you already have”A host set up before this layout has one /opt/pia/.env.prod holding both sides. pia-server no longer reads it: docker compose up -d fails with env file /opt/pia/.env.prod.server not found, and pia-server does not start until that file exists.
-
Copy the application keys — everything except
POSTGRES_PASSWORD,VECTORDB_PASSWORD,APP_SECRET,UMAMI_DB_USERandUMAMI_DB_PASSWORD— into a new/opt/pia/.env.prod.server, andchmod 600it. -
Deploy, which copies the current
docker-compose.prod.ymlonto the host.pia-serverreads.env.prod.serverfrom here on. -
Delete those keys from
.env.prod, leaving the infrastructure ones behind.postgresandumamiread nothing else from that file, so nothing needs recreating.
Keep that order: trimming .env.prod before the new compose file lands points the still-running configuration at a file that no longer holds it.
Optional: the Mesh operator runtime
Section titled “Optional: the Mesh operator runtime”docker-compose.prod.yml already defines a temporal service, but the operator runtime stays off until you say otherwise. To enable it, add to .env.prod.server:
Operators__Enabled=trueTemporal__Address=temporal:7233Temporal__Namespace=piaTemporal__TaskQueue=pia-operatorsEvery other Operators__* key has a working default — see Configuration. All four keys above are restart-only.
Three deployment facts worth knowing before you flip it on:
pia-serverdeliberately does notdepends_ontemporal. An unreachable Temporal degrades to a logged warning and an idle worker; coupling the two would turn a Temporal failure into apia-serverrestart loop and a chat outage, for a feature that defaults off.- Temporal has no published ports and no Caddy route. The default single-namespace dev-server setup has no authentication of its own. Keep it that way.
- Its state lives in Postgres, in the
temporalandtemporal_visibilitydatabases thattemporal-db-initcreates andtemporal-schema-initpopulates — two one-shots that gate the server between them. Losing that state costs in-flight assignments only;assignmentsandassignment_eventsin thepiadatabase are the record of truth.
6. Deploy
Section titled “6. Deploy”Copy the four files that carry no secrets onto the host, then start the stack. Images come from the registry you authenticated against in step 4 — nothing is built on the server.
Edit Caddyfile before you copy it: its site blocks carry the hostnames of the deployment they were written for, and Caddy requests a certificate for whatever name it finds in a block. Replace them with your own, and delete the blocks for services you are not running.
# On your local machine, from the server sourcescp docker-compose.prod.yml Caddyfile init-db.sh temporal-dynamicconfig.yaml root@<server-ip>:/opt/pia/
# On the serverssh root@<server-ip>cd /opt/piadocker compose --env-file .env.prod -f docker-compose.prod.yml pulldocker compose --env-file .env.prod -f docker-compose.prod.yml up -dEvery later upgrade is those same two commands — see Upgrading. Re-copy the four files whenever a release changes them.
7. Database roles and row-level security
Section titled “7. Database roles and row-level security”Optional, and inert until the last cutover step. It splits the single pia credential into five database roles and puts row-level security on assignments and assignment_events, enforced by which login role the connection used rather than by a session flag the application sets.
Treat it as a second layer on top of route scoping and the store seam, not as a database guarantee of tenant isolation. It stops a missing WHERE UserId = … on SELECT, UPDATE and INSERT against those two tables — with no identity set count(*) returns 0 with no error, a cross-user UPDATE reports UPDATE 0, and a cross-user INSERT fails with 42501. It does not stop a missing WHERE on DELETE: referential-integrity cascades are exempt from row-level security and users has 30 cascade children, so a role that sees zero rows in both protected tables still destroys every tenant’s assignment data with DELETE FROM users. It covers 2 of the 47 public tables — the runtime role keeps unrestricted access to users, user_roles, refresh_tokens and DataProtectionKeys — and it does nothing against host root or anyone who can docker exec. Its other real gain is the umami analytics container, which otherwise holds a superuser credential on the cluster that stores assignment plaintext.
FORCE ROW LEVEL SECURITY is on both tables. Its one benefit: ownership stops being an implicit bypass, so pg_policies is the complete answer to who can read these tables.
The five roles
Section titled “The five roles”| Role | Attributes | Owns | Credential lives in |
|---|---|---|---|
pia_root |
SUPERUSER LOGIN, no password, ever |
nothing | nowhere — reachable only as docker compose exec postgres psql -U pia_root |
pia |
LOGIN NOSUPERUSER NOBYPASSRLS NOREPLICATION CREATEDB CREATEROLE |
database pia and all 47 public tables |
nowhere persistent — composed into pia-migrate’s Database__MigrationConnectionString from POSTGRES_PASSWORD in .env.prod |
pia_app |
LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS |
nothing | Database__ConnectionString |
pia_maint |
same as pia_app |
nothing | Database__MaintenanceConnectionString |
umami_app |
same as pia_app, plus INHERIT |
database umami and every object in it |
umami’s DATABASE_URL |
pia runs DDL and migrations — from the one-shot pia-migrate container only, which Compose runs to completion before pia-server starts and which then exits. That is why the ceiling above stops where it does: ownership is what row-level security cannot fence off, so the long-running process must not hold it. pia_app is the runtime role every request uses; the user’s identity reaches the database as a pia.user_id session setting written on every connection open. pia_maint is the bypass used by the admin roll-up and the retention sweep — not a session flag, a different login. pia_root exists only so that de-privileging pia is reversible, because pia is currently the sole superuser on the cluster.
One-time provisioning
Section titled “One-time provisioning”Run by a human, once. pia_root is created without a password: a role with no SCRAM verifier cannot authenticate over TCP, and the container’s pg_hba.conf trusts local socket connections, so docker compose exec is the only way in.
cd /opt/piadocker compose -f docker-compose.prod.yml exec postgres psql -U pia -d postgres -c \ "CREATE ROLE pia_root LOGIN SUPERUSER;"
# Must print pia_root | tdocker compose -f docker-compose.prod.yml exec postgres psql -U pia_root -d postgres -c \ "SELECT rolname, rolsuper FROM pg_roles WHERE rolname = 'pia_root';"
# Over TCP instead of the socket: must be refused with "password authentication failed"docker compose -f docker-compose.prod.yml exec postgres \ psql -h postgres -U pia_root -d postgres -c "SELECT 1"Generate three passwords with openssl rand -base64 24 and create the roles as pia_root. Roles created by a superuser produce no pg_auth_members rows at all; roles created by pia would leave pia holding admin_option on them, which is exactly the membership the runtime role’s isolation depends on being absent.
-- docker compose -f docker-compose.prod.yml exec postgres psql -U pia_root -d postgresSET createrole_self_grant = '';CREATE ROLE pia_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS NOINHERIT PASSWORD '<generated-password>';CREATE ROLE pia_maint LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS NOINHERIT PASSWORD '<generated-password>';CREATE ROLE umami_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT PASSWORD '<generated-password>';
-- Must return (0 rows): neither granted to anyone, nor holding any membership themselves.SELECT roleid::regrole, member::regrole, admin_option, set_option FROM pg_auth_members WHERE roleid::regrole::text IN ('pia_app', 'pia_maint', 'umami_app') OR member::regrole::text IN ('pia_app', 'pia_maint', 'umami_app');Connection strings
Section titled “Connection strings”Three keys in /opt/pia/.env.prod.server, alongside the other server settings from step 5.
| Key | Role | Used for |
|---|---|---|
Database__ConnectionString |
pia_app |
every request |
Database__MaintenanceConnectionString |
pia_maint |
admin roll-up, retention sweep |
Giving umami its own role
Section titled “Giving umami its own role”umami must stop using the pia superuser credential before pia is de-privileged, or the container comes back up against a role that can no longer run its Prisma migrations.
As pia_root, connected to umami:
-- docker compose -f docker-compose.prod.yml exec postgres psql -U pia_root -d umami\set ON_ERROR_STOP onBEGIN;DO $do$DECLARE r record; tgt text := 'umami_app'; n int := 0;BEGIN IF current_database() <> 'umami' THEN RAISE EXCEPTION 'refusing to run outside database umami (current=%)', current_database(); END IF;
FOR r IN SELECT c.oid::regclass AS obj FROM pg_class c JOIN pg_namespace n2 ON n2.oid = c.relnamespace WHERE n2.nspname NOT IN ('pg_catalog','information_schema','pg_toast') AND c.relkind IN ('r','p','f','v','m','S') AND pg_get_userbyid(c.relowner) <> tgt -- Identity and serial sequences follow their table; altering one directly aborts the transaction. AND NOT (c.relkind = 'S' AND EXISTS ( SELECT 1 FROM pg_depend d WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.refclassid = 'pg_class'::regclass AND d.deptype IN ('a','i'))) ORDER BY CASE c.relkind WHEN 'S' THEN 2 WHEN 'v' THEN 3 WHEN 'm' THEN 3 ELSE 1 END, c.oid LOOP EXECUTE format('ALTER TABLE %s OWNER TO %I', r.obj, tgt); n := n + 1; END LOOP;
FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p JOIN pg_namespace n2 ON n2.oid = p.pronamespace WHERE n2.nspname NOT IN ('pg_catalog','information_schema') AND pg_get_userbyid(p.proowner) <> tgt LOOP EXECUTE format('ALTER ROUTINE %s OWNER TO %I', r.fn, tgt); n := n + 1; END LOOP;
FOR r IN SELECT t.oid::regtype AS ty FROM pg_type t JOIN pg_namespace n2 ON n2.oid = t.typnamespace WHERE n2.nspname NOT IN ('pg_catalog','information_schema') AND t.typtype IN ('e','d') AND pg_get_userbyid(t.typowner) <> tgt AND NOT EXISTS (SELECT 1 FROM pg_class c WHERE c.reltype = t.oid) LOOP EXECUTE format('ALTER TYPE %s OWNER TO %I', r.ty, tgt); n := n + 1; END LOOP;
RAISE NOTICE 'reassigned % object(s)', n;END $do$;
-- Only if the schema is literally owned by pia: a pg_database_owner-owned public schema needs nothing.DO $$ BEGIN IF (SELECT pg_get_userbyid(nspowner) FROM pg_namespace WHERE nspname = 'public') NOT IN ('pg_database_owner', 'umami_app') THEN ALTER SCHEMA public OWNER TO umami_app; END IF;END $$;COMMIT;Re-running it is a no-op. If it aborts, nothing moved — the transaction is the point.
Then, as pia_root connected to postgres (ALTER DATABASE cannot run inside a transaction block):
ALTER DATABASE umami OWNER TO umami_app;REVOKE CONNECT ON DATABASE umami FROM PUBLIC;GRANT CONNECT ON DATABASE umami TO umami_app;The credential move itself is two edits to /opt/pia/.env.prod, and their order is not negotiable. umami’s DATABASE_URL is composed in the compose file from UMAMI_DB_USER and UMAMI_DB_PASSWORD, which fall back to the pia credential while unset — but umami derives its session secret from DATABASE_URL when APP_SECRET is unset, so changing the username without a pinned secret logs out every umami session as an unexplained side effect.
cd /opt/piaopenssl rand -hex 32 # if step 5 did not already pin APP_SECRET# APP_SECRET=<value> in /opt/pia/.env.proddocker compose --env-file .env.prod -f docker-compose.prod.yml up -d --force-recreate umami
# Only once umami is running on a pinned secret, in the same file:# UMAMI_DB_USER=umami_app# UMAMI_DB_PASSWORD=<umami_app's password>docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --force-recreate umamiGiving the vector store its own password
Section titled “Giving the vector store its own password”pia-vectordb is a separate cluster, but with VECTORDB_PASSWORD unset it authenticates with POSTGRES_PASSWORD — and the app holds that value in Knowledge__ConnectionString. So while the two are equal, the app process still knows the main cluster’s owner password and the role split buys nothing. This is a required cutover step, not optional hardening.
cd /opt/pia# This cluster's `pia` role is unrelated to the main cluster's role of the same name.docker compose -f docker-compose.prod.yml exec pia-vectordb \ psql -U pia -d pia_knowledge -c "ALTER ROLE pia PASSWORD '<vectordb-password>';"Then put that value in both files — VECTORDB_PASSWORD in /opt/pia/.env.prod, so a rebuilt volume initializes with it, and the same password in Knowledge__ConnectionString in /opt/pia/.env.prod.server — and recreate the app:
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --force-recreate pia-serverKnowledge-base lookups fail between the ALTER ROLE and that recreate; nothing else reaches this cluster. Afterwards, 28P01 in docker compose logs pia-server means Knowledge__ConnectionString still carries the old password.
De-privileging pia
Section titled “De-privileging pia”-- as pia_rootALTER ROLE pia NOSUPERUSER NOBYPASSRLS NOREPLICATION;All three attributes, in one statement. The initdb bootstrap role carries rolbypassrls and rolreplication explicitly, so removing SUPERUSER alone leaves pia bypassing every policy while pg_policies still looks exactly right. Confirm with the query below rather than assuming.
Cutover order
Section titled “Cutover order”Every row before the connection-string change is inert: the roles exist and the policies are created, but the app still connects as the owner and is exempt from them.
Row 2 is the exception to “optional” — splitting the environment files is required on every host the moment the current docker-compose.prod.yml lands, whether or not you adopt anything else on this page.
| # | Step | Rollback |
|---|---|---|
| 1 | Back up: pg_dumpall --roles-only, then the data dump. Record the running image digest (docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/pia-ai-dev/pia-server:latest). |
— |
| 2 | Split /opt/pia/.env.prod into .env.prod + .env.prod.server (step 5). Required before any deploy, independently of everything below. |
Merge both files back into .env.prod and restore the previous docker-compose.prod.yml |
| 3 | Create and verify pia_root. Do not proceed unless both verification commands above behaved as stated. |
DROP ROLE pia_root; |
| 4 | Provision pia_app, pia_maint, umami_app. |
ALTER ROLE … NOLOGIN on all three — unusable immediately. Dropping them is separate cleanup: DROP OWNED BY must run inside database pia, or DROP ROLE fails on the dependent grants |
| 5 | Deploy the build carrying the migration, connection strings still unset. Policies land; the owner keeps its exemption. | Redeploy the recorded digest via a compose override |
| 6 | Pin APP_SECRET in .env.prod and recreate umami on it alone. |
Remove the line and recreate (sessions log out again) |
| 7 | In .env.prod.server, move Database__ConnectionString to pia_app and add Database__MaintenanceConnectionString; docker compose up -d --force-recreate pia-server. First step that enforces anything. |
Revert Database__ConnectionString to Username=pia, drop the maintenance key, recreate. Seconds, no SQL to undo |
| 8 | Soak — watch docker compose logs -f pia-server for 42501 and for at least one retention tick. Then transfer umami’s objects, set UMAMI_DB_USER / UMAMI_DB_PASSWORD in .env.prod and recreate umami. |
Unset both keys and recreate; pia is still a superuser here, so it works regardless of ownership |
| 9 | Give pia-vectordb its own password: ALTER ROLE, then VECTORDB_PASSWORD in .env.prod and the matching Knowledge__ConnectionString in .env.prod.server, recreate pia-server. |
ALTER ROLE pia PASSWORD back to the old value, revert both keys, recreate |
| 10 | ALTER ROLE pia NOSUPERUSER NOBYPASSRLS NOREPLICATION; |
ALTER ROLE pia SUPERUSER BYPASSRLS REPLICATION; as pia_root — one statement, instant, no restart |
| 11 | Verify, then re-run pg_dumpall --roles-only into the backup set. |
— |
Verifying the split
Section titled “Verifying the split”-- docker compose -f docker-compose.prod.yml exec postgres psql -U pia_root -d piaSELECT rolname, rolsuper, rolbypassrls, rolreplication, rolcanlogin FROM pg_roles WHERE rolname IN ('pia', 'pia_app', 'pia_maint', 'umami_app') ORDER BY 1;-- once pia is de-privileged, every row: f | f | f | t-- rolreplication is the discriminating column: an ALTER ROLE that dropped only two of the three-- attributes leaves pia bypassing every policy, and nothing else here shows it
SELECT count(*) FROM pg_policies WHERE schemaname = 'public' AND tablename IN ('assignments', 'assignment_events');-- 6: a user policy, a maintenance policy and an owner-exemption policy per tableThen one functional check, as pia:
SELECT count(*) FROM assignments; -- non-zeroSELECT count(*) FROM assignment_events; -- non-zeroFinally, in the app: a normal user still sees their own assignments, and the admin Mesh roll-up is not empty. The roll-up runs on pia_maint, so an empty one means the maintenance string is wrong.
init-db.shcannot provision roles./docker-entrypoint-initdb.dfires only on an empty data directory, so on an existing host the script never runs. Role provisioning is the human step above.- Production runs
:latest, so code and config cannot be rolled back independently. An older image plus apia_appconnection string means empty reads and 500s on POST. That is what the recorded image digest in cutover row 1 is for. pg_dumpall --roles-onlybelongs in the backup routine, before the data dump. Roles are cluster-wide: restoring a database dump does not recreate them, and everyGRANT … TO pia_appin that dump fails if the roles are absent. Restore roles first, application data second.- Self-healed roles are not a breach, but they do break the zero-row assertion. If
pia_apporpia_maintwas ever created by a boot’s disaster-recovery self-heal rather than bypia_root,piacreated them and holdsadmin_optionon them. Drop and recreate aspia_root. Once they are superuser-created, rotating their passwords is apia_rootoperation —piagetspermission denied to alter role.
8. Verify
Section titled “8. Verify”On the server, the long-running containers should all be up — caddy, pia-server, pia-web, pia-docs, postgres, pia-vectordb, umami and temporal, minus any you dropped from the compose file.
Four more are one-shot jobs that have already finished: pia-migrate, temporal-db-init, temporal-schema-init and temporal-namespace. Exited (0) on each is the correct state, and they stay listed in docker ps -a — one container per job, replaced in place by the next deploy rather than piling up. Leave them there: their logs are the only record of what the migration and the schema init actually did. docker compose ps without -a lists just the running services.
docker compose -f /opt/pia/docker-compose.prod.yml psdocker compose -f /opt/pia/docker-compose.prod.yml logs # all logsdocker compose -f /opt/pia/docker-compose.prod.yml logs pia-server # one serviceFrom anywhere:
curl https://<your-domain>/health # should return 200 + JSONCaddy obtains TLS certificates on first request — the very first hit may take a few seconds.
Troubleshooting
Section titled “Troubleshooting”Caddy can’t get TLS certificates
Section titled “Caddy can’t get TLS certificates”- Verify DNS records resolve to the server IP.
- Open ports 80 and 443 in your provider’s firewall.
- Read
docker compose logs caddy— ACME failures are noisy and self-explanatory.
pia-server won’t start
Section titled “pia-server won’t start”env file /opt/pia/.env.prod.server not foundmeans the environment files have not been split yet.- Confirm Postgres is healthy:
docker compose ps postgres. - Re-check both files —
POSTGRES_PASSWORDin.env.prodmust equal the password inDatabase__ConnectionStringin.env.prod.server, unless you have adopted the role split, in which case that string carriespia_app’s password andPOSTGRES_PASSWORDstayspia’s. - Read
docker compose logs pia-server.
Can’t pull images from ghcr.io
Section titled “Can’t pull images from ghcr.io”- Re-authenticate:
docker login ghcr.io(step 4). - Confirm the token you were issued still carries
read:packages. - The packages are private; an anonymous
docker pullfails withdeniedwhatever the tag.
Firewall
Section titled “Firewall”| Port | Protocol | Purpose |
|---|---|---|
| 22 | TCP | SSH |
| 80 | TCP | HTTP (Caddy redirect + ACME) |
| 443 | TCP | HTTPS |