Self-Hosting
Run your own Uplink edge from one Docker Compose file. You do not need an Uplink
account, a source checkout, Rust, Caddy, lego, or an .env file. Compose pulls a
multi-architecture uplink-edge image from Docker Hub and the official Caddy image.
The published compose.yaml includes an immutable Uplink release by default. It
will not move to a newer image until you deliberately download a newer Compose
file or set an image override.
Quick start
Use a dedicated Linux server with a public IPv4 address, root or sudo access, Docker Engine and Docker Compose, and permission to create DNS records for a domain you control.
1. Copy compose.yaml
Create a directory for the edge:
mkdir -p uplink-edge
cd uplink-edge
Create a file named compose.yaml, then copy the complete contents below into it.
The copy button appears in the upper-right corner of the code block.
name: uplink-edge
x-uplink-image: &uplink-image
# Every release updates this immutable default. UPLINK_EDGE_IMAGE remains an
# optional override for deliberate testing or rollback.
image: ${UPLINK_EDGE_IMAGE:-openlaboratoryorg/uplink-edge:0.1.42}
x-hardened: &hardened
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
services:
# One-shot interactive initializer. It writes restricted config, API-key
# hashes into edge-state, renewal credentials into renewal-state, and only
# runtime TLS/Caddy artifacts into caddy-state, then exits.
setup:
<<: *uplink-image
profiles: ["setup"]
user: "0:0"
security_opt:
- no-new-privileges:true
entrypoint:
- /usr/local/bin/uplink-edge
- container
- setup
stdin_open: true
tty: true
volumes:
- edge-state:/state
- caddy-state:/caddy-state
- renewal-state:/renewal
- ./certificates:/import:ro
# On-demand helper for key rotation and other state-aware operator commands.
# Run it explicitly, for example: docker compose run --rm operator key list ...
operator:
<<: [*uplink-image, *hardened]
profiles: ["tools"]
user: "10001:10001"
read_only: true
environment:
# Read-only host procfs files let containerized doctor report the actual
# edge host's memory and QUIC buffer settings without host privileges.
UPLINK_EDGE_DOCTOR_PROC_ROOT: /host-proc
UPLINK_EDGE_DOCTOR_CONTAINER: "1"
tmpfs:
- /tmp:size=16m,mode=1777
volumes:
- edge-state:/state
- type: bind
source: /proc/meminfo
target: /host-proc/meminfo
read_only: true
- type: bind
source: /proc/sys/net/core/rmem_max
target: /host-proc/sys/net/core/rmem_max
read_only: true
- type: bind
source: /proc/sys/net/core/wmem_max
target: /host-proc/sys/net/core/wmem_max
read_only: true
# Plain HTTP stays on the private Compose network. Only the QUIC connector
# listener is published on the host.
uplink-edge:
<<: [*uplink-image, *hardened]
restart: unless-stopped
init: true
command:
- container
- serve
- --max-streams
- ${UPLINK_EDGE_MAX_STREAMS:-1024}
user: "10001:10001"
read_only: true
environment:
UPLINK_EDGE_ALIAS_POLICY_FILE: ${UPLINK_EDGE_ALIAS_POLICY_FILE:-}
UPLINK_EDGE_PER_CONNECTOR_STREAM_CAP: ${UPLINK_EDGE_PER_CONNECTOR_STREAM_CAP:-}
OPENTUNNELS_QUIC_MAX_BIDI_STREAMS: ${OPENTUNNELS_QUIC_MAX_BIDI_STREAMS:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
tmpfs:
- /tmp:size=16m,mode=1777
ports:
- target: 4100
published: "${UPLINK_EDGE_TUNNEL_PORT:-4100}"
host_ip: "${UPLINK_EDGE_BIND_IP:-0.0.0.0}"
protocol: udp
volumes:
- edge-state:/state:ro
- caddy-state:/caddy-state:ro
- ./configuration:/operator:ro
ulimits:
nofile:
soft: 1048576
hard: 1048576
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"]
interval: 15s
timeout: 5s
retries: 8
start_period: 10s
# Caddy owns browser ingress and speaks HTTP/1.1, HTTP/2, and HTTP/3. The
# generated Caddyfile authenticates forwarded client-IP headers to the edge.
# Its admin API is available only through a root-only Unix socket in /data so
# renewal can reload certificates without sharing Caddy's PID namespace.
caddy:
<<: *hardened
image: caddy:2.11.4-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648
restart: unless-stopped
read_only: true
cap_add:
- NET_BIND_SERVICE
command: ["caddy", "run", "--config", "/caddy-state/Caddyfile", "--adapter", "caddyfile"]
ports:
- target: 80
published: "${UPLINK_EDGE_HTTP_PORT:-80}"
host_ip: "${UPLINK_EDGE_BIND_IP:-0.0.0.0}"
protocol: tcp
- target: 443
published: "${UPLINK_EDGE_HTTPS_PORT:-443}"
host_ip: "${UPLINK_EDGE_BIND_IP:-0.0.0.0}"
protocol: tcp
- target: 443
published: "${UPLINK_EDGE_HTTP3_PORT:-443}"
host_ip: "${UPLINK_EDGE_BIND_IP:-0.0.0.0}"
protocol: udp
volumes:
- caddy-state:/caddy-state:ro
- caddy-data:/data
tmpfs:
- /tmp:size=16m,mode=1777
- /config:size=16m,mode=0700
depends_on:
uplink-edge:
condition: service_healthy
healthcheck:
test: ["CMD", "caddy", "validate", "--config", "/caddy-state/Caddyfile"]
interval: 30s
timeout: 5s
retries: 3
# The edge image carries the official lego binary. In managed-certificate
# mode it checks daily and retries failures hourly; own-certificate mode stays
# idle so the same Compose file covers both setup choices.
certificate-renewal:
<<: [*uplink-image, *hardened]
restart: unless-stopped
command:
- container
- renew
- --watch
# Root owns Lego state; the fixed runtime group makes atomic replacement
# certificates readable by the non-root edge without CAP_CHOWN.
user: "0:10001"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
volumes:
- renewal-state:/renewal
- caddy-state:/caddy-state
- caddy-data:/caddy-data
depends_on:
caddy:
condition: service_healthy
volumes:
edge-state:
caddy-state:
renewal-state:
caddy-data:
Check the file before continuing:
docker compose config
2. Prepare the Linux host
Uplink uses QUIC for browser HTTP/3 and connector tunnels. Apply the persistent 8 MiB UDP socket-buffer limits once:
sudo tee /etc/sysctl.d/90-uplink-edge.conf >/dev/null <<'EOF'
# Host kernel caps for Uplink's QUIC UDP socket buffers.
net.core.rmem_max = 8388608
net.core.wmem_max = 8388608
EOF
sudo sysctl --system
Allow these inbound ports in both your cloud firewall and the host firewall:
| Port | Purpose |
|---|---|
80/tcp | HTTP redirect and certificate support |
443/tcp | HTTPS over HTTP/1.1 and HTTP/2 |
443/udp | HTTPS over HTTP/3 |
4100/udp | Uplink connector tunnels |
Compose owns ports 80 and 443. Stop an existing web server on those ports or use a separate host.
If the server has less than 2 GiB RAM and no swap, add at least 1 GiB of persistent
swap. On a fresh server without an existing /swapfile:
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
grep -q '^/swapfile ' /etc/fstab || \
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Skip those commands if /swapfile already exists; inspect the current values with
free -h and swapon --show instead.
3. Initialize the edge
docker compose run --rm setup
The setup wizard:
- detects the server’s public address;
- prints the apex and wildcard DNS-only
Arecords to create, then waits for them; - obtains and renews a wildcard certificate through a lego-supported DNS provider, or imports an existing wildcard certificate; and
- creates a connector API key and prints its raw value once.
Secret values entered in the wizard are not echoed. Store the connector key before closing the terminal; only its verification record is persisted. Save the raw key in a file readable only by you:
umask 077
printf '%s\n' '<uek-key-printed-by-setup>' > uplink-edge.key
4. Start and verify it
docker compose up -d
docker compose ps
docker compose logs --tail=100
Both uplink-edge and caddy should report healthy. Verify public HTTPS after DNS
has propagated:
curl -fsS https://edge.example.com/healthz
Your edge is now ready for application machines.
Connect an application
Install Uplink on the machine running your application, then add the edge with the protected key file copied from the edge operator:
uplink edge add edge.example.com --api-key-file uplink-edge.key
uplink serve 3000 --open --edges edge.example.com
No Uplink login or --account flag is needed. After edge add succeeds, Uplink has
stored the credential in its own protected state and the copied key file can be
removed from the application machine.
Bearer-token routes work without managed visitor identity:
uplink serve 3000 --auth <access-token> --edges edge.example.com
Self-hosted edges are stored locally on each connector machine. Use uplink edge list, uplink edge refresh <domain>, and uplink edge remove <domain> to manage
that registration.
Certificate choices
Automatic DNS-01
The bundled lego client obtains a wildcard certificate using narrowly scoped
DNS-provider credentials. Some providers need multiple KEY=VALUE values; setup
accepts them individually and rejects duplicate names. The renewal container checks
daily, retries failures hourly, installs renewed certificates, and reloads Caddy.
Bring your own certificate
Create ./certificates, place the PEM certificate and key there, then choose paths
under /import in the wizard. You remain responsible for renewal. After replacing
the files, re-import and apply them with:
docker compose run --rm setup \
--force \
--domain edge.example.com \
--tls-cert /import/fullchain.pem \
--tls-key /import/privkey.pem
docker compose restart
docker compose up -d
Forced setup preserves connector keys and proxy secrets. If existing state is
unreadable, it fails instead of silently replacing secrets. --force --reset-secrets is the explicit recovery option when regeneration is intentional.
The same restart followed by up -d is safe for ordinary whole-edge recovery.
The second command reapplies health-gated startup ordering if one service becomes
ready before another.
Updates and rollback
The Compose text on this page pins a specific Uplink release. Back up the four named
volumes and archive the current file before updating. Then return to the top of this
page and copy the current compose.yaml text over your local file:
cp compose.yaml compose.yaml.before-update
docker compose config
docker compose pull
docker compose up -d
docker compose ps
Compose recreates changed containers while preserving named volumes. To roll back,
restore compose.yaml.before-update and run docker compose up -d again.
For a deliberate persistent override, replace PREVIOUS_VERSION with an existing
Docker Hub release and create an optional .env file:
printf 'UPLINK_EDGE_IMAGE=openlaboratoryorg/uplink-edge:PREVIOUS_VERSION\n' > .env
docker compose pull
docker compose up -d
Remove .env to return to the version embedded in compose.yaml. Rollback is
supported while releases use the same container-state schema; release notes call
out migrations.
Back up edge-state, renewal-state, caddy-state, and caddy-data as
sensitive data. Never run docker compose down -v unless you intend to delete
the edge configuration, connector keys, renewal credentials, certificates, and
Caddy state.
Provider-backed admission
API-key admission is the standalone default. To use a compatible identity provider instead, pass its URL to setup:
docker compose run --rm setup \
--connector-auth identity \
--identity-provider https://identity.example.com
Supplying only --identity-provider keeps API-key connector admission and enables
visitor SSO when discovery advertises it. Add --challenge-provider and
--challenge-provider-token for bot protection. See the
identity-provider and
challenge-provider contracts.
On an application machine, provider-backed connector admission uses:
uplink login --auth https://identity.example.com
uplink edge add edge.example.com \
--connector-auth identity \
--identity-provider https://identity.example.com
uplink serve 3000 --edges edge.example.com
Diagnose an edge
Start with service health and recent logs:
docker compose ps
docker compose logs --tail=100 uplink-edge caddy certificate-renewal
curl -fsS https://edge.example.com/healthz
A passing HTTPS probe checks 443/tcp; verify separately that 443/udp and
4100/udp are allowed by both firewalls. Run the operator’s end-to-end checks for
DNS, certificates, providers, and public reachability:
docker compose run --rm operator doctor --domain edge.example.com
docker compose run --rm operator --output=json doctor --domain edge.example.com
Supply the expected public IP to make DNS comparison explicit, or disable address auto-detection for externally observable checks only:
docker compose run --rm operator doctor \
--domain edge.example.com --public-ip 203.0.113.10
docker compose run --rm operator doctor \
--domain edge.example.com --no-public-ip-detect
docker compose run --rm operator doctor --env-reference prints the complete
environment-variable reference.
The published Compose file mounts only the host memory and UDP-buffer procfs files
needed by doctor, read-only, so those two checks describe the Linux host rather
than the operator container. Host and cloud firewall rules cannot be inspected from
inside a container; doctor reports that limitation and the ports to verify
manually.
Scripted setup output
Non-interactive provisioning can request a typed JSON document with
--output=json. The one-time key is the top-level connectorApiKey field rather
than text embedded in a message. Redirect the complete document to a protected
temporary file, extract that field, then delete the document because it also
contains the raw key:
umask 077
docker compose run --rm -T setup --yes \
--domain edge.example.com \
--public-ip 203.0.113.10 \
--tls-cert /import/fullchain.pem \
--tls-key /import/privkey.pem \
--output=json > setup-result.json
jq -r '.connectorApiKey // empty' setup-result.json > uplink-edge.key
rm setup-result.json
test -s uplink-edge.key
Supply the equivalent --dns-provider, --dns-credential, and --acme-email
arguments instead of the certificate paths when automating DNS-01 issuance.
API-key operations
The edge re-reads its key file for every connector registration, so rotation and revocation need no restart:
docker compose run --rm operator key list --file /state/api-keys.json
docker compose run --rm operator key revoke --file /state/api-keys.json --id <key-id>
docker compose run --rm operator key rotate \
--file /state/api-keys.json --overlap-seconds 300
rotate prints the replacement raw key once. Key changes affect new and
reconnecting registrations; revocation does not tear down an established tunnel.
Custom domains and telemetry
A Compose-installed edge can route operator-owned aliases from a YAML file. It validates the initial file, hot-reloads valid edits, and retains the last valid policy after an invalid edit. See Self-hosted aliases for configuration, DNS, verification, and removal.
Self-hosted traffic is not sent to Uplink’s hosted analytics. To export metadata-only
telemetry to your own collector, put OTEL_EXPORTER_OTLP_ENDPOINT in an optional
.env and run docker compose up -d uplink-edge. Request headers and bodies are not
exported.
What Compose runs
| Service or volume | Purpose |
|---|---|
uplink-edge | Edge origin and public connector QUIC listener |
caddy | Browser TLS and guarded custom-domain certificates |
certificate-renewal | Daily lego renewal, idle with imported certificates |
setup | Explicit one-shot initializer |
operator | Non-root helper for diagnostics and API-key operations |
edge-state | Edge configuration, proxy secrets, and API-key records |
renewal-state | Restricted DNS credentials and lego account |
caddy-state | Generated Caddyfile and certificate material |
caddy-data | Persistent Caddy runtime data |
The long-running containers use read-only root filesystems, dropped capabilities, non-root execution where possible, restart policies, and health checks. Each secret class is mounted only by the services that need it.
Managed and standalone capabilities
Both edge types run the same request-routing and policy engine. The surrounding services differ:
| Capability | Managed edge | Self-hosted + provider | API-key only |
|---|---|---|---|
| Connector admission | Account/device identity | Provider identity or API key | Edge-minted API key |
| Public routes and password/bearer access | Yes | Yes | Yes |
| Allowed IPs, request limits, and bandwidth caps | Yes | Yes | Yes |
| Firewall block, rate-limit, and redirect rules | Yes | Yes | Yes |
Account-private visitor identity and share | Yes | With visitorSso discovery | No |
| Bot protection and challenge rules | Yes | With a challenge provider | No |
| Custom-domain aliases | Dashboard-managed | Operator-owned YAML | Operator-owned YAML |
| Traffic visibility | Hosted views | Optional operator OTLP | Optional operator OTLP |
| Multi-host availability | Managed region | Operator-designed | Operator-designed |
A standalone Compose installation is one host and one tenant. Compose restarts failed processes but does not replicate live route state or provide managed-region failover. Every accepted API key publishes into the same edge account label; key IDs are rotation handles, not separate tenant scopes.
Build from source instead
The published image is recommended. To build the exact services locally, clone the Uplink source repository and add its source-only override:
git clone https://github.com/openlaboratory/uplink.git
cd uplink/deploy/self-host
docker compose -f compose.yaml -f compose.build.yaml build
docker compose -f compose.yaml -f compose.build.yaml run --rm setup
docker compose -f compose.yaml -f compose.build.yaml up -d
Keep both -f arguments for later source-built operations.
Serving connectors from boot
Compose uses restart: unless-stopped, bringing the edge and Caddy back when the
container engine starts. The separate sudo uplink service install command belongs
on an application machine; it reconnects that machine’s saved tunnels after reboot.
See uplink service.
Advanced direct run
Running uplink-edge without setup starts a plaintext HTTP origin, not a public
HTTPS server. Keep it on loopback or a private network behind a correctly configured
TLS proxy. The proxy must stamp the token paired with
UPLINK_EDGE_TRUSTED_PROXY_HOPS and UPLINK_EDGE_TRUSTED_PROXY_TOKEN; otherwise
client-IP policies see the proxy instead of the visitor. The guided Compose setup is
the supported way to generate that Caddy/token pair.
Custom supervisors can use uplink-edge doctor --env-reference for the environment
contract and uplink-edge --help for listener flags.
Local development edge
The local preset is a plaintext *.localhost edge for Docker and integration
tests:
uplink-edge local --domain myproject.localhost \
--tunnel-cert tests/e2e/fixtures/localhost.cert.der \
--tunnel-key tests/e2e/fixtures/localhost.key.der
It serves visitors on 127.0.0.1:8080 and connector tunnels on 0.0.0.0:4100.
Like any API-key-only edge, it cannot require account visitor identity.