Uplink Docs
Self-Hosting
Documentation

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.

One file, pinned release

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:

PortPurpose
80/tcpHTTP redirect and certificate support
443/tcpHTTPS over HTTP/1.1 and HTTP/2
443/udpHTTPS over HTTP/3
4100/udpUplink 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 A records 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.

Volumes contain secrets

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 volumePurpose
uplink-edgeEdge origin and public connector QUIC listener
caddyBrowser TLS and guarded custom-domain certificates
certificate-renewalDaily lego renewal, idle with imported certificates
setupExplicit one-shot initializer
operatorNon-root helper for diagnostics and API-key operations
edge-stateEdge configuration, proxy secrets, and API-key records
renewal-stateRestricted DNS credentials and lego account
caddy-stateGenerated Caddyfile and certificate material
caddy-dataPersistent 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:

CapabilityManaged edgeSelf-hosted + providerAPI-key only
Connector admissionAccount/device identityProvider identity or API keyEdge-minted API key
Public routes and password/bearer accessYesYesYes
Allowed IPs, request limits, and bandwidth capsYesYesYes
Firewall block, rate-limit, and redirect rulesYesYesYes
Account-private visitor identity and shareYesWith visitorSso discoveryNo
Bot protection and challenge rulesYesWith a challenge providerNo
Custom-domain aliasesDashboard-managedOperator-owned YAMLOperator-owned YAML
Traffic visibilityHosted viewsOptional operator OTLPOptional operator OTLP
Multi-host availabilityManaged regionOperator-designedOperator-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.

Search across 15 pages.
↑↓ navigate openEsc close