Self-hosted artifact registries in 2026: Nexus 3 with Cargo, Dart, Docker, RubyGems, and APT
Running a private artifact registry is one of those infrastructure decisions that pays for itself quickly once you have more than a couple of projects. Build times drop because dependencies resolve locally. You get a pull-through cache for public registries. And you have a place to publish your own packages without going through npmjs, crates.io, or pub.dev.
For the Sol network — a self-hosted home lab running Lockamy Studios projects — I needed a single registry that could handle five different package formats: Docker images, Rust crates (Cargo), Ruby gems (Jekyll themes), Debian packages (APT), and Dart packages (pub.dev). Nexus 3 handles all of them, but the documentation for some formats — especially Dart — is thin. Here’s what actually works.
The setup
Nexus 3 runs as a Docker container deployed via Terraform. The full deployment is in the softsurve/sol repo, but the container configuration is straightforward:
resource "docker_container" "nexus" {
name = "nexus"
image = "sonatype/nexus3:3.68.0"
restart = "unless-stopped"
env = [
"INSTALL4J_ADD_VM_PARAMS=-Xms1200m -Xmx1200m -XX:MaxDirectMemorySize=2g ...",
"NEXUS_SECURITY_RANDOMPASSWORD=false"
]
ports {
internal = 8081
external = 8081 # Web UI
}
ports {
internal = 5000
external = 5000 # Docker registry
}
ports {
internal = 5001
external = 5001 # Dart/pub registry
}
}
One thing worth noting: vm.max_map_count=262144 is required on the host for Nexus to run without warnings. Set it in /etc/sysctl.conf or it’ll bite you after a reboot.
Repository provisioning via REST API
Nexus 3 has no official Terraform provider for repository management. The cleanest approach is a null_resource with a remote-exec provisioner that calls the Nexus REST API after the container is healthy.
The provisioner polls the health endpoint first:
for i in $(seq 1 40); do
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
http://localhost:8081/service/rest/v1/status)
[ "$STATUS" = "200" ] && break
sleep 15
done
Then creates repositories. The pattern for each format is the same — hosted (your packages), proxy (cache of the public registry), group (unified endpoint combining both):
nexus_api() {
METHOD=$1; ENDPOINT=$2; BODY=$3
HTTP=$(curl -s -o /tmp/resp.txt -w '%{http_code}' \
-u "$NEXUS_USER:$NEXUS_PASS" \
-X "$METHOD" -H 'Content-Type: application/json' \
"http://localhost:8081/service/rest$ENDPOINT" \
${BODY:+-d "$BODY"})
# 400 = already exists — treat as no-op for idempotency
[ "$HTTP" = "201" ] || [ "$HTTP" = "200" ] || [ "$HTTP" = "400" ] || exit 1
}
Docker
Docker needs two separate nginx endpoints — one for the web UI and one for the registry connector. Nexus exposes the Docker registry on a separate port (5000), and Docker clients need to talk to that directly.
nexus_api POST /v1/repositories/docker/hosted '{
"name": "docker-hosted",
"docker": { "v1Enabled": false, "forceBasicAuth": true, "httpPort": 5000 }
}'
In nginx, the Docker registry endpoint needs proxy_buffering off and client_max_body_size 0 — image layers can be large and chunked transfer will break without these.
Cargo (Rust)
Nexus has native Cargo support. Configure cargo to use the group endpoint in .cargo/config.toml:
[registries.lockamy]
index = "https://nexus.example.com/repository/cargo-hosted/"
[source.crates-io]
replace-with = "lockamy-group"
[source.lockamy-group]
registry = "https://nexus.example.com/repository/cargo-group/"
In the Jenkinsfile, credentials are written at build time:
mkdir -p ~/.cargo
cat >> ~/.cargo/config.toml <<EOF
[registries.lockamy]
index = "${NEXUS_URL}/repository/cargo-hosted/"
EOF
printf '[registries.lockamy]\ntoken = "%s:%s"\n' \
"${NEXUS_USER}" "${NEXUS_PASS}" >> ~/.cargo/credentials.toml
chmod 0600 ~/.cargo/credentials.toml
cargo publish --registry lockamy
RubyGems
Straightforward. Push to the hosted repo, pull from the group:
gem push mypackage-1.0.0.gem \
--host https://nexus.example.com/repository/rubygems-hosted/ \
--key admin:PASSWORD
In a Gemfile:
source "https://nexus.example.com/repository/rubygems-group/"
APT (Debian/Ubuntu packages)
The APT proxy is the most useful part for a homelab — it caches Ubuntu packages locally so you’re not pulling from the upstream archive on every server provision.
One gotcha: the distribution field in the repository config (jammy, focal, etc.) has to match what your APT clients expect. Get it wrong and apt-get update will fail with a confusing error about missing Release files.
Dart/pub — the underdocumented one
Nexus added native Dart/pub support in version 3.65. Before that, people were hacking around it with raw/generic repositories. As of 3.68, it works properly.
The Dart registry needs its own port connector, separate from the web UI. That means an additional port mapping on the container (5001 in this setup) and a separate nginx server block:
server {
listen 443 ssl;
server_name dart.nexus.example.com;
location / {
proxy_pass http://127.0.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The repository creation:
nexus_api POST /v1/repositories/dart/hosted '{
"name": "dart-hosted",
"storage": { "blobStoreName": "default", "writePolicy": "allow" },
"dart": {}
}'
nexus_api POST /v1/repositories/dart/proxy '{
"name": "dart-pubdev-proxy",
"proxy": { "remoteUrl": "https://pub.dev", "contentMaxAge": 1440 },
"dart": {}
}'
nexus_api POST /v1/repositories/dart/group '{
"name": "dart-group",
"group": { "memberNames": ["dart-hosted", "dart-pubdev-proxy"] },
"dart": {}
}'
To use it, set PUB_HOSTED_URL before any flutter or dart pub command:
export PUB_HOSTED_URL=https://dart.nexus.example.com/repository/dart-group/
flutter pub get
For publishing a package, point publish_to in pubspec.yaml:
publish_to: https://dart.nexus.example.com/repository/dart-hosted/
Nginx configuration
Each format gets its own server block. The critical settings that vary by format:
| Format | client_max_body_size |
proxy_buffering |
Notes |
|---|---|---|---|
| Docker | 0 (unlimited) |
off |
Chunked transfer for image layers |
| Cargo | 0 |
default | Crate files can be large |
| RubyGems | 0 |
default | Gem files vary |
| APT | 0 |
default | Package files can be large |
| Dart | 0 |
default | Standard |
Jenkins credential pattern
Every Jenkinsfile uses the same credential ID:
withCredentials([usernamePassword(
credentialsId: 'nexus-credentials',
usernameVariable: 'NEXUS_USER',
passwordVariable: 'NEXUS_PASS'
)]) {
// push artifacts
}
One credential ID, consistent across every pipeline. When you rotate the Nexus admin password, you update it in one place and re-deploy Jenkins — all pipelines pick it up automatically.
Version requirement
Nexus ≥ 3.65.0 is required for native Dart/pub support. Earlier versions will accept the repository creation API call but won’t actually serve the Dart protocol correctly. Pin your image tag.