Published · Updated · By Elliot Jackson
Point AWS SDKs and the AWS CLI at a local S3 endpoint
Configure the AWS CLI, TypeScript, Python, Go, and Rust clients against one local S3-compatible endpoint using a shared smoke test.
If an S3 client is pointed at a local server, four values need to agree: the endpoint, credentials, region, and URL addressing style. A useful first checklist for “the SDK can’t connect to my local S3” is to look for one of those values being missing, overridden, or applied to a different client than the one making the request.
This guide builds one mental model and applies it to the AWS CLI, the AWS SDK for JavaScript v3, boto3, the AWS SDK for Go v2, and the AWS SDK for Rust. The examples use http://localhost:9000, local-only placeholder credentials, and us-east-1; replace those values with the connection details shown by your server.
The important boundary is that a client successfully constructing an S3 object proves very little. The useful test is a request round trip: create a bucket, write an object, list it, read it back, and clean up. Run that same small test against each client you care about.
The four values every local client needs
Endpoint
The endpoint is the base URL for requests. For a local server it will usually be an HTTP loopback address, such as:
http://localhost:9000
The endpoint changes where the request goes; it does not remove the other parts of S3 request configuration.
Credentials
S3 clients still sign requests, even when the server is on your laptop. Use the access key and secret generated by your local server, or the explicit fixture values it documents. Never put a real AWS access key in a local tutorial or point a local smoke test at a production endpoint.
The values below are examples only:
export AWS_ACCESS_KEY_ID=local-access-key
export AWS_SECRET_ACCESS_KEY=local-secret-key
Region
The region is part of the client's configuration and, for Signature Version 4 requests, part of the signing scope. Use the region configured by your local server. us-east-1 is a common local default, but it is not a universal requirement.
For SDKs, AWS_REGION is the portable environment variable to prefer. The AWS CLI also understands AWS_DEFAULT_REGION; its documentation says AWS_REGION takes precedence when both are set. The safest approach is to set one deliberately and check the profile or shell that launches the command.
Addressing style
S3 clients can address an object using a path such as http://localhost:9000/bucket/key, or by putting the bucket into the hostname. The latter is often called virtual-hosted-style addressing.
Those forms are not interchangeable for every local server. A server may only support path-style requests, and a virtual-hosted request can require local DNS or host-header handling. If your server documents a path-style requirement, enable the client option for it. Do not assume that an option with the same name exists in every SDK.
I'm building Objectively as a local S3 development tool. It's my product, and its public page shows the endpoint and basic client configuration for five clients. The examples below use path-style addressing because some local servers require it; use the addressing style documented by the server you are running.
The examples below show the same four settings in each client: endpoint, local credentials, region, and path-style addressing. Use current compatible releases and pin them according to your project’s normal dependency policy.
| Client | Path-style setting |
|---|---|
| AWS CLI | Use the endpoint explicitly and set addressing_style = path in local configuration when needed |
| AWS SDK for JavaScript v3 | forcePathStyle: true |
| boto3 | Config(s3={"addressing_style": "path"}) |
| AWS SDK for Go v2 | UsePathStyle = true |
| AWS SDK for Rust | force_path_style(true) |
Which setting wins?
AWS has a useful precedence model for custom endpoints. In broad terms, an explicit endpoint on a client or command wins over environment and shared configuration. A service-specific environment variable wins over the global endpoint variable, and shared profile configuration comes after those values.
For an S3 request, the practical order is:
- An endpoint passed directly to the client, or
--endpoint-urlon an AWS CLI command. - A service-specific endpoint such as
AWS_ENDPOINT_URL_S3. - The global
AWS_ENDPOINT_URLenvironment variable. - An S3
endpoint_urlin a namedservicessection of the shared AWS config. - A profile-level
endpoint_urlin the shared AWS config. - AWS's normal endpoint resolution.
That order comes from AWS's service-specific endpoint configuration reference. The same page notes that an explicit endpoint on a client is still used even when configured endpoint URLs are being ignored.
Start with an explicit value while debugging. Once the request works, move the value into an environment variable or profile if that makes the project easier to run. Keeping one source of truth avoids accidentally sending a command to AWS whilst the application uses localhost.
Set up a local shell and run one smoke test
Use a shell-scoped configuration first. The function below runs in a subshell, validates an exact HTTP loopback endpoint with a numeric port before any AWS command, fails on the first failed command, and removes only the bucket objects and temporary files it creates. AWS_ENDPOINT_URL is global for AWS services, so the guard also prevents a copied or changed environment value from sending this walkthrough to AWS or another host.
The AWS CLI's S3 addressing style is a profile-level setting. The function creates a disposable profile with addressing_style = path; change that setting only when the local server documents a different addressing requirement.
run_local_s3_smoke_test() (
set -euo pipefail
export AWS_ENDPOINT_URL="${AWS_ENDPOINT_URL:-http://localhost:9000}"
# Refuse userinfo, paths, query strings, non-loopback hosts, and non-numeric ports.
if [[ ! "$AWS_ENDPOINT_URL" =~ ^http://(localhost|127[.]0[.]0[.]1):[0-9]+$ ]]; then
printf 'Refusing endpoint outside exact HTTP loopback form: %s\n' "$AWS_ENDPOINT_URL" >&2
exit 1
fi
local_port="${AWS_ENDPOINT_URL##*:}"
if (( 10#$local_port < 1 || 10#$local_port > 65535 )); then
printf 'Refusing invalid TCP port: %s\n' "$local_port" >&2
exit 1
fi
readonly endpoint="$AWS_ENDPOINT_URL"
export AWS_ACCESS_KEY_ID=local-access-key
export AWS_SECRET_ACCESS_KEY=local-secret-key
export AWS_REGION=us-east-1
temp_dir="${TMPDIR:-/tmp}"
config_file=''
local_file=''
downloaded_file=''
ownership_file=''
local_bucket=''
bucket_created=0
cleanup() {
# Only address the exact generated bucket after the ownership marker matches.
if [[ "$bucket_created" == 1 \
&& -n "$endpoint" \
&& -n "$ownership_file" \
&& "$ownership_file" == "$local_file".bucket-created \
&& -f "$ownership_file" ]]; then
marker_endpoint="$(sed -n '1p' "$ownership_file")"
marker_bucket="$(sed -n '2p' "$ownership_file")"
marker_file="$(sed -n '3p' "$ownership_file")"
if [[ "$marker_endpoint" == "$endpoint" \
&& "$marker_bucket" == "$local_bucket" \
&& "$marker_file" == "$local_file" \
&& "$local_bucket" =~ ^objectively-local-smoke-[a-f0-9]{24}$ ]]; then
aws --endpoint-url "$endpoint" s3 rm "s3://$local_bucket/hello.txt" || true
aws --endpoint-url "$endpoint" s3 rb "s3://$local_bucket" || true
fi
fi
if [[ -n "$local_file" && "$local_file" == "$temp_dir"/objectively-local-s3-smoke.* ]]; then
rm -f -- "$downloaded_file" "$ownership_file" "$local_file"
fi
if [[ -n "$config_file" && "$config_file" == "$temp_dir"/objectively-aws-config.* ]]; then
rm -f -- "$config_file"
fi
}
trap cleanup EXIT
config_file="$(mktemp "$temp_dir/objectively-aws-config.XXXXXX")"
local_file="$(mktemp "$temp_dir/objectively-local-s3-smoke.XXXXXX")"
downloaded_file="${local_file}.downloaded"
ownership_file="${local_file}.bucket-created"
local_bucket="objectively-local-smoke-$(uuidgen | tr '[:upper:]' '[:lower:]' | tr -d '-' | cut -c1-24)"
cat > "$config_file" <<'EOF'
[profile local-s3]
region = us-east-1
s3 =
addressing_style = path
EOF
export AWS_CONFIG_FILE="$config_file"
export AWS_PROFILE=local-s3
printf 'local S3 smoke test\n' > "$local_file"
# set -e makes bucket creation and every upload fail fast; no later AWS command runs.
aws --endpoint-url "$endpoint" s3 mb "s3://$local_bucket"
bucket_created=1
printf '%s\n%s\n%s\n' "$endpoint" "$local_bucket" "$local_file" > "$ownership_file"
aws --endpoint-url "$endpoint" s3 cp "$local_file" "s3://$local_bucket/hello.txt"
aws --endpoint-url "$endpoint" s3 ls "s3://$local_bucket"
aws --endpoint-url "$endpoint" s3 cp "s3://$local_bucket/hello.txt" "$downloaded_file"
cmp "$local_file" "$downloaded_file"
)
run_local_s3_smoke_test
The command-line endpoint takes precedence over configured endpoint values. Keep any one-off CLI command inside the same loopback guard; do not copy an unguarded command into a shell that may contain an AWS or remote endpoint.
The smoke test is intentionally small. A successful cmp tells you that the client reached an endpoint, the endpoint accepted the request, and the object returned by the GET matches the uploaded fixture. It does not by itself prove that the endpoint validates the supplied credentials, or prove multipart uploads, presigned URLs, versioning, ACLs, or every S3 operation.
TypeScript with AWS SDK for JavaScript v3
The JavaScript v3 client accepts an explicit endpoint when it is constructed. Keep credentials and the region in the same configuration boundary so it is clear which values sign the request.
import { ListBucketsCommand, S3Client } from "@aws-sdk/client-s3";
const endpoint = process.env.AWS_ENDPOINT_URL ?? "http://localhost:9000";
const region = process.env.AWS_REGION ?? "us-east-1";
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
if (!accessKeyId || !secretAccessKey) {
throw new Error("Set local AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY first");
}
const s3 = new S3Client({
endpoint,
region,
credentials: { accessKeyId, secretAccessKey },
// Use path style when the local server requires it.
forcePathStyle: true,
});
const response = await s3.send(new ListBucketsCommand({}));
console.log(response.Buckets);
The AWS SDK for JavaScript v3 S3 client reference documents the endpoint and forcePathStyle options. Here, endpoint is read explicitly from AWS_ENDPOINT_URL, so it is the value this client receives. forcePathStyle is an addressing choice, not a general “make local S3 work” switch. Remove it only after verifying that the server and your DNS setup support virtual-hosted-style requests.
The Objectively landing page publishes this client family and endpoint shape. Use a current compatible JavaScript SDK release and pin it according to your project’s normal dependency policy.
Python with boto3
With boto3, the equivalent values are named endpoint_url, region_name, aws_access_key_id, and aws_secret_access_key. The Boto3 configuration guide documents the client configuration surface and credential sources:
import os
import boto3
from botocore.config import Config
def local_s3_client():
return boto3.client(
"s3",
endpoint_url=os.environ.get("AWS_ENDPOINT_URL", "http://localhost:9000"),
region_name=os.environ.get("AWS_REGION", "us-east-1"),
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
config=Config(s3={"addressing_style": "path"}),
)
s3 = local_s3_client()
print(s3.list_buckets()["Buckets"])
The explicit endpoint_url means this client does not depend on the shared AWS config file to discover the local service. Config(s3={"addressing_style": "path"}) uses boto3's documented configuration API to put the bucket name in the request path.
Presigned boto3 requests with explicit SigV4
Presigned requests add another configuration boundary. The Boto3 presigned URL guide recommends explicitly configuring Signature Version 4. For a local endpoint, pair that with the path style documented by the server you are running:
presign_config = Config(
signature_version="s3v4",
s3={"addressing_style": "path"},
)
presigned_put = boto3.client(
"s3",
endpoint_url=os.environ.get("AWS_ENDPOINT_URL", "http://localhost:9000"),
region_name=os.environ.get("AWS_REGION", "us-east-1"),
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
config=presign_config,
)
url = presigned_put.generate_presigned_url(
"put_object",
Params={"Bucket": "your-local-bucket", "Key": "hello.txt"},
ExpiresIn=60,
)
Treat this as an interoperability recommendation rather than a claim that every local server requires SigV4. Use a disposable bucket, then verify the URL expiry and body round trip when adapting the example.
Go with AWS SDK for Go v2
The Go v2 SDK exposes endpoint configuration through its config loader. The config package reference documents config.WithBaseEndpoint, and the S3 options reference documents UsePathStyle. Keep the region and local credentials explicit in a small test program:
package main
import (
"context"
"fmt"
"os"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func main() {
ctx := context.Background()
endpoint := os.Getenv("AWS_ENDPOINT_URL")
if endpoint == "" {
endpoint = "http://localhost:9000"
}
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(valueOrDefault("AWS_REGION", "us-east-1")),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
os.Getenv("AWS_ACCESS_KEY_ID"),
os.Getenv("AWS_SECRET_ACCESS_KEY"),
"",
)),
config.WithBaseEndpoint(endpoint),
)
if err != nil {
panic(err)
}
client := s3.NewFromConfig(cfg, func(options *s3.Options) {
options.UsePathStyle = true
})
result, err := client.ListBuckets(ctx, &s3.ListBucketsInput{})
if err != nil {
panic(err)
}
fmt.Println(result.Buckets)
}
func valueOrDefault(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
This is the same precedence idea in a different API: the endpoint supplied to the loader becomes part of this client's configuration. UsePathStyle is the AWS SDK for Go v2 client option for path-style requests.
Rust with aws-sdk-s3
Rust separates the shared AWS configuration from the generated S3 client. The Rust S3 configuration builder documents force_path_style; set the region, endpoint, and local credentials on the loader before creating the client:
use aws_config::{BehaviorVersion, Region};
use aws_sdk_s3::config::Credentials;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let endpoint = std::env::var("AWS_ENDPOINT_URL")
.unwrap_or_else(|_| "http://localhost:9000".to_string());
let region = std::env::var("AWS_REGION")
.unwrap_or_else(|_| "us-east-1".to_string());
let access_key = std::env::var("AWS_ACCESS_KEY_ID")?;
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")?;
let credentials = Credentials::new(
access_key,
secret_key,
None,
None,
"local-s3",
);
let shared_config = aws_config::defaults(BehaviorVersion::latest())
.region(Region::new(region))
.endpoint_url(endpoint)
.credentials_provider(credentials)
.load()
.await;
let config = aws_sdk_s3::config::Builder::from(&shared_config)
.force_path_style(true)
.build();
let client = aws_sdk_s3::Client::from_conf(config);
let response = client.list_buckets().send().await?;
println!("{:?}", response.buckets());
Ok(())
}
The Rust SDK's Config::builder().force_path_style(true) is the service-level API for forcing path-style requests. It is applied after loading the shared endpoint, region, and credentials so the generated client does not fall back to virtual-hosted addressing when the server requires path style.
A troubleshooting model that scales across languages
When one client fails, change one of the four values at a time and prove the result with the request feed or the server's logs. Avoid changing endpoint, credentials, region, and addressing mode simultaneously; that makes a successful retry impossible to explain.
| Symptom | First proof step | Likely boundary to inspect |
|---|---|---|
| Connection refused or timeout | Check that the local server is running on the exact host and port in the endpoint, then rerun the guarded smoke test. | Endpoint, process state, or port |
| Access denied or signature error | Confirm the access key, secret, and region came from the same local environment. If the server feed contains the request, that is useful corroboration; an absent entry is not proof that no request arrived. | Credentials or signing region |
| The request reaches the wrong server | Rerun the guarded smoke test, which uses its explicit loopback endpoint, then inspect the shell's AWS_ENDPOINT_URL and the active profile. | Endpoint precedence |
| The bucket becomes a hostname and the request fails | Inspect the request URL. If the local server only supports path style, enable the current SDK's path-style setting. | Addressing style and local DNS |
| The client reports an unsupported operation | Reduce the test to create/list/put/get/delete, then check the server's documented operation matrix. | Product compatibility subset |
| The CLI works but the SDK does not | Compare the final endpoint, region, credentials, and addressing setting, not just the source code that builds the client. | SDK-specific precedence or defaults |
A request feed is useful when it records a request: the request details it exposes can help distinguish a server response from a connection failure. Treat it as supporting evidence rather than a complete log, and use client-side errors alongside it. A request feed and object browser do not replace compatibility or cloud integration tests.
Keep local credentials local
These placeholder credentials are deliberately boring. Use the values generated for the local endpoint and keep them out of source control. Keep them local:
- Set values in a shell session or an ignored
.envfile, not in a committed example. - Use a separate AWS profile if the CLI configuration is shared with real accounts.
- Make the endpoint explicit in scripts that can also run against AWS.
- Never paste a real secret into an issue, screenshot, request feed, or sample repository.
- Treat local authentication as a way to exercise the client signing path, not as a reproduction of AWS IAM policies or production access control.
What the smoke test proves
The shared smoke test establishes a useful baseline: the client can reach the endpoint and complete a simple object round trip while exercising that client's configured signing path. It does not establish AWS IAM semantics or prove compatibility beyond those operations. Add focused checks for every other operation your application uses, and keep an Amazon S3 integration test wherever provider behaviour matters.
If you are developing on a Mac and want a native local endpoint with copy-ready connection details, Objectively is one option to evaluate. It is a local development tool, not an AWS compatibility certificate; use the connection details from the environment you create, follow that environment's documented addressing and signing requirements, and test the operations your application actually needs.