# Public ports & database access
URL: https://synquic.tech/docs/guides/ports.md

Publish TCP or UDP ports on your server for traffic a domain can't carry, keep a port on localhost for SSH tunnels, and expose a database to the internet with one switch.

import { Step, Steps } from 'fumadocs-ui/components/steps';

Websites and APIs reach your app through a **domain**: the edge proxy on ports 80 and 443 takes the request
and hands it to the container. Some traffic can't go that way: a database client, an SMTP or IMAP server, a
game server, a UDP service, a raw TCP protocol. For those you **publish a port**: the server opens it and
forwards it straight into the container.

Nothing is published by default. Inside a project, services already reach each other on their private
network (see [Internal networking](/docs/guides/networking)), so you only publish what something **outside**
the project must reach.

<Callout title="What you need first">
- A project that runs a container on one of your own servers. A static site has no container, so it has no
  ports to publish.
- Permission to change the project's settings.
</Callout>

## Where the Ports editor is

- **A single app:** open the project and choose **Ports** in the project menu.
- **A compose or monorepo project:** ports are set per service. The project's **Ports** entry says so and
  links to **Apps & Services**; open the service and go to its **Settings** tab. The **Ports** card is at the
  top.

## Publish a port

<Steps>

<Step>

### Open the editor

Press **Add port** (or **Edit ports** when some are already published).

</Step>

<Step>

### Fill in the row

| Field | What to enter |
| --- | --- |
| **Published port** | The port opened on the server, for example `25565`. |
| **Target port** | The port the process listens on inside the container, for example `25565`. |
| **Protocol** | **TCP**, **UDP** or **TCP and UDP**. |
| **Bind to** | **Public** listens on every network interface of the server. **Localhost** listens on `127.0.0.1` only. |

Press **Add port** for another row, or the trash icon to remove one. Up to 50 ports per service or app.

</Step>

<Step>

### Save

Press **Save ports**. If the app or service is running, Synq recreates its container with the new
bindings right away, keeping its image, environment, volumes, internal name and domains. The message says
**Ports saved and applied to the running container.** If nothing is deployed yet, it says **Ports saved.
They apply on the next deploy.**

</Step>

</Steps>

Each row bound to **Public** then shows **Reachable at** with the server address and port, for example
`Reachable at 203.0.113.5:25565` (when Synq knows the server's address). The address is the server's
connection hostname or the panel's domain when one points at the server (see
[Which host the address uses](#which-host-the-address-uses)). A **Localhost** row shows no address.

If the save worked but the container swap failed (for example a deployment was running), the card keeps the
new list and shows **Apply now**. Press it once the deployment has finished.

<Callout title="Published ports turn off zero-downtime deploys" type="info">
A fixed port on the server can't be bound by two containers at once. So once a single app publishes any port,
each deploy stops the old container before starting the new one, even with **Zero downtime** on (see
[Container settings](/docs/guides/container-settings#zero-downtime-single-apps)). Expect a short gap during
deploys; the deploy log says why.
</Callout>

### Public or Localhost

- **Public** is for clients on the internet or another server: a database you manage from your laptop, a
  mail server, a game server.
- **Localhost** is for tools on the server itself and for SSH tunnels. Nothing outside the server can
  connect, but you can still reach it from your computer through SSH:

```bash
# Forward local port 5432 to a Localhost-bound port 5432 on the server
ssh -N -L 5432:127.0.0.1:5432 root@203.0.113.5
```

Then point your client at `localhost:5432`.

### Ports you can't use

Synq refuses a port it needs for itself. The editor marks the row, for example **Port 443 is reserved by
Synq on this server.** A request made directly to the API gets a message that names what the port is
reserved for. The reserved ports are:

| Port | Reserved for |
| --- | --- |
| `80`, `443` | The edge proxy (HTTP and HTTPS for your domains). |
| `9145` | The edge's management API. |
| `20000` to `29999` | Synq's internal routing. The edge reaches each app on a private loopback port in this range. |
| `4000` and `3001` (on the server that runs Synq) | The Synq API and panel. If you changed their ports, the reserved ports follow. |

It also refuses a port that is already taken on the **same server**: by another row in the list, by another
service or project (of any team) that publishes it, or by a program that is already listening on it. The
error names who holds it, nothing is saved, and the row offers **Use port N** with a free port next to it
(or **Auto-assign**, which leaves the choice to Synq). Two different servers can publish the same port.

A database's own port (5432 for PostgreSQL, for example) can't be published below `1024`: those ports are
kept for system services. Other services can use them, so a mail server can still publish `25` and a DNS
server `53`.

Every change to published ports on a server (this editor, **Public access** for a database, a service
update over the API, a compose import or sync, an app install) takes that server's lock from the moment it
checks which ports are free until it has saved (and applied) its own, so two changes made at the same moment
never end up with the same port. When applying the new ports to the running container fails, the saved list
goes back to what it was and the message says why.

Over the API, the answers are:

| Answer | When |
| --- | --- |
| `409 PORT_UNAVAILABLE` | A well-formed port something else holds (another service or project, a port Synq reserves, a program on the server). `issues[]` names each row's holder with a `suggestedPort`, and the body repeats the first as `suggestedPort`. |
| `400 PORT_PRIVILEGED` | A database's port published below `1024`, with a `suggestedPort`. |
| `400 INVALID_PORTS` | The request itself is malformed: a port outside 1 to 65535, two rows on one port, a routed port bound to Localhost. |
| `409 PORT_IN_USE` | Docker couldn't bind the port when the container was recreated. Nothing was changed. |

One more rule: an app's routed port (the one its domain uses) can't be published on **Localhost** over TCP,
because it is already reachable on the server through the domain. Publish it **Public** or target another
port.

An app that runs [replicas](/docs/guides/replicas) can't publish ports at all: a host port can be held by one
container only. Set **Replicas** back to 1 first; the edge reaches every replica without a published port.

<Callout title="Public ports skip the host firewall" type="warn">
Docker publishes ports with its own firewall rules, so `ufw` and similar host firewalls **do not block
them**. Publish only what must be reachable, protect the service with a strong password, and restrict
source IPs at your cloud provider's firewall (security group) where you can.
</Callout>

## Public access for a database

A database service (PostgreSQL, MySQL, MariaDB, MongoDB, Redis or Valkey) is private to its project by
default. To connect to it from your computer or from another server, turn on **Public access** instead of
typing port rows.

<Steps>

<Step>

### Open the database service

Open the project, go to **Apps & Services** and click the database. The **Public access** card is on its
**Overview** tab. (It only appears when Synq recognises the image as one of the databases above.)

</Step>

<Step>

### Turn on Expose to the internet

Switch on **Expose to the internet**. Leave **Public port** empty to use the engine's standard port
(`5432`, `3306`, `27017` or `6379`), or the next free port when another app on the server already uses it.
Type a port, for example `15432`, to pick one yourself.

</Step>

<Step>

### Confirm

Read the warning and press **Expose database**. If the service is running, its container is updated right
away and the toast says the database is now reachable from the internet. If the service isn't deployed yet,
the toast says **Saved. It applies on the next deploy.**

</Step>

</Steps>

The card then shows two addresses, each with a copy button:

- **Internal**, from other services in this project, for example `db:5432`.
- **External**, from anywhere on the internet, for example `db.example.com:5432` or `203.0.113.5:5432`.

When Synq can read the database's credentials, it also shows full connection URLs with the password hidden.
Copy one and put in the real password yourself:

```bash
psql "postgresql://app:your-password@db.example.com:5432/app"
```

The same card is on the project's connection details as **External access**, with a ready-to-run command
line and an SSH tunnel command.

To close it again, switch **Expose to the internet** off. The public port is removed and the database is
private again. Any other port rows the service has are left alone. This also works when the container is
already gone: the port is removed and nothing is published on the next deploy.

### Which host the address uses

Under the address, the card says which host it uses and why, for example **Using app.example.com (points at
this server).** Synq picks it in this order:

1. The server's **connection hostname**, when you set one (server page, **Security** tab, **Connection
   hostname**, or `synq server update <server-id> --public-hostname db.example.com`). Synq uses it exactly as
   typed and does not check where it points.
2. For a server you added over SSH with a DNS name, that name.
3. For the server Synq runs on: the panel's own hostname, then your
   [apps domain](/docs/dashboard/settings#apps-domain), each **only when its public DNS points straight at this
   server's IP address**. A name behind a proxy (for example Cloudflare's orange cloud) or pointed at another
   machine is skipped, and the card says so, because a database client can't connect through an HTTP proxy.
   `sslip.io` addresses are never used here; they only spell out the IP.
4. Otherwise, the server's IP address.

Synq caches the DNS answers for five minutes. If a lookup fails, the card uses the IP address rather than
failing.

<Callout title="A hostname is not a secret" type="info">
Anyone can look up the hostname and find the server's address, just as they can scan the IP. It makes the
URL easier to read, not private. For private access, use a **Localhost** port and an SSH tunnel.
</Callout>

### Several databases on one server

Each database needs its own public port, since two containers can't listen on the same port of one server.
With the port left empty, the first PostgreSQL gets `5432`, the next `5433`, then `5434`, even when you turn
them on at the same moment. The card shows the port that was actually assigned in its URL and command line,
and says why it isn't the standard one, for example **Port 5432 is used by service "db" in "Shop", so
PostgreSQL uses port 5433.**

A port you type is never moved. If it can't be used, nothing changes and the card offers a free port instead:

| Why | Answer |
| --- | --- |
| Another service or project on the server publishes it | `409`, names the holder and fills in a free port |
| Synq reserves it (the edge, the panel, internal routing) | `409`, says what it is reserved for and fills in a free port |
| A program outside Synq listens on it | `409`, names the program and fills in a free port |
| It is below `1024` | `400`: ports below 1024 are kept for system services |

If updating the running container fails (Docker can't bind the port, a deployment is in progress, or the
server can't be reached), Synq puts the container and the saved ports back as they were and says why. If
Docker starts the container but doesn't publish the port, the card says so instead of showing an address
that won't connect; redeploy the service and check again.

<Callout title="Your cloud firewall must allow the port" type="warn">
Synq can't see your cloud provider's firewall from inside the server. On AWS, for example, add an inbound
rule for the port to the instance's security group, ideally only from your own IP address.
</Callout>

<Callout title="Anyone can try to connect" type="warn">
An exposed database gets scanned and brute-forced within minutes. Use a long random password, allow only
your own IP addresses at your provider's firewall where you can, and turn public access off when you no
longer need it. For occasional access, a **Localhost** port plus an SSH tunnel is safer.
</Callout>

## Compose files and `ports:`

A service imported from a `docker-compose.yml` keeps the `ports:` entries it declares. A `host:container`
entry with no interface is published on **localhost** (`127.0.0.1`), not on every interface as plain Docker
would do; write `0.0.0.0:5432:5432` in the file, or use the editor, to make it public. Entries the editor
can't represent (a container port on its own, a specific IP address, a port range, `sctp`) are listed under
**Kept as written** and left untouched; change those in the service settings or the file.

## From the API

```bash
# List a single app's published ports, reserved ports and server address
curl -H "Authorization: Bearer $SYNQ_TOKEN" \
  "$SYNQ_URL/api/projects/$PROJECT_ID/ports"

# Replace them: publish 25565 over TCP on every interface, and apply now
curl -X PUT -H "Authorization: Bearer $SYNQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"ports":[{"hostPort":25565,"containerPort":25565,"protocol":"tcp","bind":"public"}],"apply":true}' \
  "$SYNQ_URL/api/projects/$PROJECT_ID/ports"

# Expose a database service on port 15432
curl -X PUT -H "Authorization: Bearer $SYNQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled":true,"hostPort":15432}' \
  "$SYNQ_URL/api/projects/$PROJECT_ID/services/$SERVICE_ID/public-access"
```

`SYNQ_URL` is your panel address (for example `https://app.example.com`). Services use
`/api/projects/:id/services/:serviceId/ports` with the same body, and both have a `/ports/apply` endpoint
that re-applies the saved list to the running container.

The public-access answer carries `serverHost` (what clients dial) and `connectionHost`, which says how it was
chosen: `source` is `server-hostname`, `ssh-host`, `panel-domain`, `apps-domain` or `ip`, and `skipped`
lists names that were tried and not used (`mismatch` when they point elsewhere, `unresolved` when DNS
didn't answer). `defaultPortHeldBy` names who holds the standard port when the database uses another one.
After a save, `portReassignments` lists ports that moved, and `unboundPorts` lists ports Docker didn't
publish. A refused port answers `409` (`PORT_UNAVAILABLE`) or `400` (`PORT_PRIVILEGED`) with a
`suggestedPort` you can send instead.

## Troubleshooting

<Callout title="Port 443 is reserved" type="error">
Web traffic on 80 and 443 belongs to the edge. Serve the app through a
[domain](/docs/guides/custom-domains) instead of publishing those ports.
</Callout>

<Callout title="The port is already in use" type="error">
Another project, another service, or a program on the server holds it. The message names the holder and
nothing is saved. Press **Use port N** to take the free port Synq found, free the port where it is used, or
choose a different published port; the target port inside the container can stay the same.
</Callout>

<Callout title="I published a port but can't connect from outside" type="warn">
Check that the row is **Public**, not **Localhost**, and that your cloud provider's firewall or security
group allows the port. Docker bypasses `ufw` on the server itself, but not your provider's network firewall.
</Callout>

## Related guides

<Cards>
  <Card title="Internal networking" href="/docs/guides/networking" description="How services reach each other without any public port." />
  <Card title="Custom domains" href="/docs/guides/custom-domains" description="Serve web traffic on your own domain with automatic HTTPS." />
  <Card title="Backups & restore" href="/docs/guides/backups-restore" description="Protect the database you just exposed." />
</Cards>
